Merge campaign-cc2: CC2 CharacterCreate wire, review-closed

CC2 review PASS (checksum term set confirmed against the CG_Pack
accumulator; account-outside-body and GetPackSize=172 independently
proven), fix round e77ebf10 (F1 latch scope + pin test, AD-100, ACE
double-NameInUse note, creationFailed reason/name split, pointer fix,
retail-discriminator citations), narrow re-review CLOSED, residual
anchor fix 95e95bb6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 13:39:08 +02:00
commit 55fc51ed8c
17 changed files with 1660 additions and 41 deletions

View file

@ -0,0 +1,152 @@
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;
}
}

View file

@ -0,0 +1,319 @@
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail character-creation request (opcode <c>0xF656</c>). Campaign CC
/// slice CC2 — the outbound half of retail creation; the shared <c>0xF643</c>
/// response is <see cref="CharGenVerificationResponse"/> (see that type's doc
/// comment for the two-family opcode collision with
/// <see cref="CharacterRestore"/>, and <c>WorldSession</c>'s awaiting-request
/// latch for how the two are disambiguated on receipt).
///
/// <para>
/// Wire layout ported byte-for-byte from
/// <c>Proto_UI::SendCharGenResult@0x00546a70</c> (packs the account name,
/// then calls <c>ACCharGenResult::Pack@0x005c7570</c> →
/// <c>ACCharGenResult::CG_Pack@0x005c7200</c>) and cross-checked against
/// ACE's <c>CharacterCreateInfo.Unpack</c> / <c>Appearance.Unpack</c>
/// (<c>ACE.Entity/CharacterCreateInfo.cs</c>, <c>ACE.Entity/Appearance.cs</c>)
/// and holtburger's <c>CharacterCreateRequestData</c>
/// (<c>holtburger-protocol/src/messages/character/types.rs:236-369</c>),
/// which agree on every field and its order:
/// </para>
///
/// <code>
/// u32 opcode (0xF656)
/// String16L accountName (packed OUTSIDE CG_Pack, by SendCharGenResult itself)
/// -- ACCharGenResult::CG_Pack body --
/// u32 constant (always 1 — CG_Pack@0x005c7208)
/// u32 heritage
/// u32 gender
/// u32 eyesStrip
/// u32 noseStrip
/// u32 mouthStrip
/// u32 hairColor
/// u32 eyeColor
/// u32 hairStyle
/// u32 headgearStyle
/// u32 headgearColor
/// u32 shirtStyle
/// u32 shirtColor
/// u32 trousersStyle
/// u32 trousersColor
/// u32 footwearStyle
/// u32 footwearColor
/// f64 skinShade
/// f64 hairShade
/// f64 headgearShade
/// f64 shirtShade
/// f64 trousersShade
/// f64 footwearShade
/// u32 template
/// u32 strength
/// u32 endurance
/// u32 coordination
/// u32 quickness
/// u32 focus
/// u32 self
/// u32 slot (ACE: CharacterSlot — NOT the character guid)
/// u32 classId
/// u32 numSkills (MUST be exactly <see cref="SkillAdvancementClassCount"/>)
/// u32[] skillAdvancementClasses (numSkills entries)
/// String16L name
/// u32 startArea
/// u32 isAdmin
/// u32 isEnvoy (ACE: IsSentinel)
/// u32 checksum (see <see cref="ComputeChecksum"/>)
/// </code>
///
/// <para>
/// <b>The 55-slot invariant.</b> ACE's <c>PlayerFactory.Create</c>
/// (reached from <c>CharacterHandler.CharacterCreateEx</c>) rejects a
/// client/server skill-table mismatch by TERMINATING the session
/// (<c>PlayerFactory.CreateResult.ClientServerSkillsMismatch</c> →
/// <c>session.Terminate(SessionTerminationReason.ClientVersionIncorrect, ...)</c>)
/// — there is no graceful recovery from sending the wrong count. Retail's
/// live skill table has exactly <see cref="SkillAdvancementClassCount"/>
/// (55) skills, so <see cref="BuildRequestBody"/> takes
/// <c>skillAdvancementClasses</c> as a <see cref="ReadOnlySpan{T}"/> and
/// throws <see cref="ArgumentException"/> for any length other than 55 —
/// structurally impossible to send anything else through this builder.
/// </para>
///
/// <para>
/// <b>The trailing checksum.</b> Retail computes and sends it
/// (<c>CG_Pack@0x005c74c3</c>, the final <c>*(uint32_t*)ecx_33 =
/// (ebx_18 + self)</c> store); ACE's <c>CharacterCreateInfo.Unpack</c> never
/// reads it (the reader consumes <c>isSentinel</c> and stops — see
/// <c>ACE.Entity/CharacterCreateInfo.cs:67</c>) and holtburger's
/// <c>CharacterCreateRequestData::unpack</c> agrees (its field list ends at
/// <c>is_sentinel</c>, no checksum read). We compute and send it anyway for
/// byte fidelity with a genuine retail client. Decompiled accumulation
/// order (<c>CG_Pack@0x005c7213</c>-<c>0x005c74c3</c>) sums EXACTLY:
/// heritage, gender, the three appearance strips (eyes/nose/mouth),
/// hairColor, eyeColor, hairStyle, headgearStyle, shirtStyle, trousersStyle,
/// footwearStyle, template, and the six attributes (strength through self).
/// Notably ABSENT from the sum despite being adjacent fields on the wire:
/// headgearColor, shirtColor, trousersColor, footwearColor, all six f64
/// shades, slot, and classId — <see cref="ComputeChecksum"/> mirrors that
/// exact (and exactly that) field set. u32 addition is commutative and
/// associative modulo 2^32, so summation order does not affect the result;
/// <see cref="ComputeChecksum"/> orders the terms for readability, not
/// wire fidelity.
/// </para>
///
/// <para>
/// <b>Routing.</b> <c>Proto_UI::SendCharGenResult</c> sends via
/// <c>Proto_UI::SendToLogon@0x00546b03</c> — the SAME queue as
/// <see cref="CharacterDelete"/>'s request
/// (<c>Proto_UI::SendDeleteCharacter@0x00546b83</c>, also <c>SendToLogon</c>)
/// and <c>CharacterEnterWorld</c>'s request
/// (<c>Proto_UI::SendEnterWorld@0x00546c12</c>). <c>WorldSession</c>'s outbound
/// helper, <c>SendCharacterCreation</c>, sends on
/// <c>GameMessageGroup.LoginQueue</c> — the same queue
/// <c>WorldSession.SendDeleteCharacter</c> already uses.
/// </para>
///
/// <para>
/// <b>Account-name gate.</b> ACE's <c>CharacterCreate</c> handler
/// (<c>CharacterHandler.cs:27-32</c>) silently drops the request when the
/// packed account name doesn't match <c>session.Account</c> — the same
/// silent-no-reply shape <see cref="CharacterRestore"/>'s doc comment already
/// warns about for restore. <c>WorldSession</c>'s awaiting-request latch
/// must never assume a reply is coming.
/// </para>
///
/// <para>
/// <b>ACE double-sends <c>NameInUse</c> (CC2 review F3).</b>
/// <c>CharacterHandler.CharacterCreateEx</c> calls
/// <c>IsCharacterNameAvailable</c> TWICE — once at the top and once after
/// <c>PlayerFactory.Create</c> — and the first callback's <c>return</c>
/// exits only the lambda, so a duplicate name yields TWO <c>0xF643</c>
/// <c>NameInUse</c> replies. The first consumes the latch; the second hits
/// <c>WorldSession</c>'s unrequested-response drop path (register AD-100)
/// and logs "unexpected CharacterGenerationVerificationResponse". During a
/// connected gate against ACE that log line is EXPECTED after a
/// duplicate-name rejection, not an acdream defect — and CC3's verification
/// gate must not treat the second reply as an error.
/// </para>
/// </summary>
public static class CharacterCreate
{
public const uint Opcode = 0xF656u;
/// <summary>
/// Retail's live skill-advancement-class table size. ACE terminates the
/// session on any other count — see the class doc comment.
/// </summary>
public const int SkillAdvancementClassCount = 55;
/// <summary>
/// The fourteen style/color strip fields plus the six f64 shade fields —
/// <c>Appearance.Unpack</c>'s exact field set and order
/// (<c>ACE.Entity/Appearance.cs</c>).
/// </summary>
public readonly record struct Appearance(
uint EyesStrip,
uint NoseStrip,
uint MouthStrip,
uint HairColor,
uint EyeColor,
uint HairStyle,
uint HeadgearStyle,
uint HeadgearColor,
uint ShirtStyle,
uint ShirtColor,
uint TrousersStyle,
uint TrousersColor,
uint FootwearStyle,
uint FootwearColor,
double SkinShade,
double HairShade,
double HeadgearShade,
double ShirtShade,
double TrousersShade,
double FootwearShade);
/// <summary>The six primary attributes, retail's fixed str/end/coord/quick/focus/self order.</summary>
public readonly record struct Attributes(
uint Strength,
uint Endurance,
uint Coordination,
uint Quickness,
uint Focus,
uint Self);
/// <summary>
/// Every field of an outbound CharacterCreate EXCEPT the account name
/// (a separate <see cref="BuildRequestBody"/> parameter, packed outside
/// <c>CG_Pack</c> — see the class doc comment) and the skill-advancement
/// array (a <see cref="ReadOnlySpan{T}"/> parameter so its length is
/// validated at the call site rather than smuggled through a record
/// field of unbounded size).
/// </summary>
public readonly record struct Request(
uint Heritage,
uint Gender,
Appearance Appearance,
uint Template,
Attributes Attributes,
uint Slot,
uint ClassId,
string Name,
uint StartArea,
bool IsAdmin,
bool IsEnvoy);
/// <summary>
/// Build the body bytes for an outbound <c>CharacterCreate</c> request.
/// See the class doc comment for the exact byte layout.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="skillAdvancementClasses"/>.Length is not exactly
/// <see cref="SkillAdvancementClassCount"/> — ACE terminates the session
/// on any other count, so this builder refuses to construct the request
/// at all rather than send something retail-invalid.
/// </exception>
public static byte[] BuildRequestBody(
string accountName,
Request request,
ReadOnlySpan<uint> skillAdvancementClasses)
{
ArgumentNullException.ThrowIfNull(accountName);
ArgumentNullException.ThrowIfNull(request.Name);
if (skillAdvancementClasses.Length != SkillAdvancementClassCount)
{
throw new ArgumentException(
"retail's CG_Pack numSkills must be exactly "
+ $"{SkillAdvancementClassCount} — ACE terminates the session "
+ "(PlayerFactory.CreateResult.ClientServerSkillsMismatch) on "
+ $"any other count. Got {skillAdvancementClasses.Length}.",
nameof(skillAdvancementClasses));
}
Appearance appearance = request.Appearance;
Attributes attributes = request.Attributes;
var w = new PacketWriter(
256 + (skillAdvancementClasses.Length * 4) + (request.Name.Length * 2));
w.WriteUInt32(Opcode);
w.WriteString16L(accountName);
// -- ACCharGenResult::CG_Pack body --
w.WriteUInt32(1u); // CG_Pack@0x005c7208 constant
w.WriteUInt32(request.Heritage);
w.WriteUInt32(request.Gender);
w.WriteUInt32(appearance.EyesStrip);
w.WriteUInt32(appearance.NoseStrip);
w.WriteUInt32(appearance.MouthStrip);
w.WriteUInt32(appearance.HairColor);
w.WriteUInt32(appearance.EyeColor);
w.WriteUInt32(appearance.HairStyle);
w.WriteUInt32(appearance.HeadgearStyle);
w.WriteUInt32(appearance.HeadgearColor);
w.WriteUInt32(appearance.ShirtStyle);
w.WriteUInt32(appearance.ShirtColor);
w.WriteUInt32(appearance.TrousersStyle);
w.WriteUInt32(appearance.TrousersColor);
w.WriteUInt32(appearance.FootwearStyle);
w.WriteUInt32(appearance.FootwearColor);
w.WriteDouble(appearance.SkinShade);
w.WriteDouble(appearance.HairShade);
w.WriteDouble(appearance.HeadgearShade);
w.WriteDouble(appearance.ShirtShade);
w.WriteDouble(appearance.TrousersShade);
w.WriteDouble(appearance.FootwearShade);
w.WriteUInt32(request.Template);
w.WriteUInt32(attributes.Strength);
w.WriteUInt32(attributes.Endurance);
w.WriteUInt32(attributes.Coordination);
w.WriteUInt32(attributes.Quickness);
w.WriteUInt32(attributes.Focus);
w.WriteUInt32(attributes.Self);
w.WriteUInt32(request.Slot);
w.WriteUInt32(request.ClassId);
w.WriteUInt32((uint)skillAdvancementClasses.Length);
foreach (uint skill in skillAdvancementClasses)
w.WriteUInt32(skill);
w.WriteString16L(request.Name);
w.WriteUInt32(request.StartArea);
w.WriteUInt32(request.IsAdmin ? 1u : 0u);
w.WriteUInt32(request.IsEnvoy ? 1u : 0u);
w.WriteUInt32(ComputeChecksum(request));
return w.ToArray();
}
/// <summary>
/// Retail's trailing checksum field — see the class doc comment for the
/// exact decompiled accumulation and the fields deliberately absent from
/// it. ACE never reads this field; acdream sends it for byte fidelity
/// with a genuine retail client.
/// </summary>
public static uint ComputeChecksum(Request request)
{
Appearance a = request.Appearance;
Attributes b = request.Attributes;
return unchecked(
request.Heritage
+ request.Gender
+ a.EyesStrip
+ a.NoseStrip
+ a.MouthStrip
+ a.HairColor
+ a.EyeColor
+ a.HairStyle
+ a.HeadgearStyle
+ a.ShirtStyle
+ a.TrousersStyle
+ a.FootwearStyle
+ request.Template
+ b.Strength
+ b.Endurance
+ b.Coordination
+ b.Quickness
+ b.Focus
+ b.Self);
}
}

View file

@ -1,4 +1,3 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
@ -75,11 +74,28 @@ namespace AcDream.Core.Net.Messages;
/// 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.
/// alone — it must track which outbound request
/// (<see cref="BuildRequestBody"/> vs.
/// <see cref="AcDream.Core.Net.Messages.CharacterCreate.BuildRequestBody"/>)
/// it is awaiting a reply to.
/// </para>
///
/// <para>
/// <b>Campaign CC CC2 update:</b> character creation now exists
/// (<see cref="AcDream.Core.Net.Messages.CharacterCreate"/>), so the
/// disambiguation this doc comment used to defer is real work now, done by
/// <c>WorldSession</c>'s awaiting-request latch (set by
/// <c>WorldSession.SendRestoreCharacter</c> /
/// <c>WorldSession.SendCharacterCreation</c>, cleared on the matching
/// response), which routes each 0xF643 to
/// <c>WorldSession.CharacterRestoreReceived</c> or
/// <c>WorldSession.CharacterCreateResponseReceived</c> accordingly and drops
/// (rather than misattributes) a 0xF643 with no outstanding request. The
/// wire parse itself is now shared: <see cref="Parse"/> delegates to
/// <see cref="CharGenVerificationResponse.Parse"/>, which both families
/// consume. This type's own <see cref="Parsed"/> shape and <see cref="Parse"/>
/// signature are UNCHANGED by that refactor — every existing caller and test
/// keeps working exactly as before.
/// </para>
/// </summary>
public static class CharacterRestore
@ -119,32 +135,14 @@ public static class CharacterRestore
/// <summary>
/// Parse a <c>CharacterRestore</c> response body (opcode <c>0xF643</c>).
/// <paramref name="body"/> must start with the 4-byte opcode.
/// <paramref name="body"/> must start with the 4-byte opcode. Delegates
/// to the shared <see cref="CharGenVerificationResponse.Parse"/> (Campaign
/// CC CC2); this type's <see cref="Parsed"/> shape and this method's
/// exception behavior are unchanged from before that refactor.
/// </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;
CharGenVerificationResponse.Parsed shared = CharGenVerificationResponse.Parse(body);
return new Parsed(shared.RawCode, shared.Guid, shared.Name, shared.SecondsGreyedOut);
}
}

View file

@ -95,6 +95,13 @@ public sealed class PacketWriter
_position += 4;
}
public void WriteDouble(double value)
{
EnsureCapacity(8);
BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan(_position), value);
_position += 8;
}
/// <summary>Pad with zeros so the buffer length is a multiple of 4.</summary>
public void AlignTo4()
{

View file

@ -598,6 +598,16 @@ public sealed class WorldSession : IDisposable
public event Action<CharacterList.Parsed>? CharacterListReceived;
public event Action? CharacterDeleteAcknowledged;
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
/// <summary>
/// Campaign CC CC2: fires when a <c>0xF643</c>
/// (<see cref="CharGenVerificationResponse"/>) response arrives while
/// this session's awaiting-request latch says <c>Create</c> — i.e. the
/// reply to <see cref="SendCharacterCreation"/>. See
/// <see cref="CharGenVerificationResponse"/>'s doc comment for the
/// opcode collision with <see cref="CharacterRestoreReceived"/> and how
/// the two are disambiguated.
/// </summary>
public event Action<CharGenVerificationResponse.Parsed>? CharacterCreateResponseReceived;
public event Action<CharacterError.Parsed>? CharacterErrorReceived;
/// <summary>
/// Campaign LA gate round 2 finding 3: ACE sends this in the same batch
@ -706,6 +716,60 @@ public sealed class WorldSession : IDisposable
public ServerName.Parsed? ServerInfo { get; private set; }
private CharacterError.Parsed? _lastCharacterSelectionError;
/// <summary>
/// Campaign CC CC2: which outbound character-generation request (if any)
/// this session is awaiting a <c>0xF643</c>
/// (<see cref="CharGenVerificationResponse"/>) reply to. Restore and
/// create requests share that opcode on the wire (see
/// <see cref="CharGenVerificationResponse"/>'s doc comment) with no
/// self-describing discriminant, so this latch is the only thing that
/// tells the dispatcher which event to fire. Retail's own discriminator
/// is structurally the same latch: <c>Handle_CharGenVerificationResponse
/// @0x0055E8B0</c> case 1 branches on
/// <c>GetVerificationState() == PENDING</c> → new CharacterIdentity +
/// AddIdentity (create) versus not-pending → unpack into the existing
/// identity at <c>slot</c> (restore). Set by
/// <see cref="SendRestoreCharacter"/> / <see cref="SendCharacterCreation"/>
/// immediately before the send; cleared the moment a matching 0xF643 is
/// dispatched (success OR parse failure — a malformed reply must not
/// wedge the latch open forever) and on session teardown
/// (<see cref="Dispose"/>).
///
/// <para>SCOPE, stated exactly (CC2 review F1): this latch correlates
/// the SINGLE outstanding request. It does NOT refuse overlapping
/// requests — a second send while one is outstanding OVERWRITES the
/// latch and the first request's reply is then delivered to the wrong
/// event. Refusing overlap is the CALLER's job, exactly as in retail:
/// <c>gmCharGenMainUI::DoFinish@0x004e9170</c> only sends when the
/// verification state is UNDEF (CC3's Runtime verification gate owns
/// that rule here). The overwrite behavior is pinned by
/// <c>WorldSessionCharacterCreationTests</c> so CC3 cannot silently
/// regress against it.</para>
///
/// <para>Read/written only from the caller's frame thread — the same
/// single-threaded invariant every other per-session field here (e.g.
/// <see cref="_lastCharacterSelectionError"/>) relies on;
/// <see cref="ProcessDatagram"/> is never invoked concurrently with a
/// send (see <see cref="ProbeNetLogOutbound"/>'s doc comment — the
/// #260 thread-id probe note; CC2 review F5 corrected this pointer).</para>
/// </summary>
private enum PendingCharGenVerificationRequest
{
None,
Restore,
Create,
}
private PendingCharGenVerificationRequest _pendingCharGenVerification =
PendingCharGenVerificationRequest.None;
/// <summary>
/// One-shot guard so an unexpected 0xF643 (no outstanding create/restore
/// request) logs exactly once per session rather than spamming on a
/// misbehaving or replaying server.
/// </summary>
private bool _loggedUnexpectedCharGenVerificationResponse;
private readonly IWorldSessionTransport _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
private long _lastPingRequestTicks;
@ -1823,18 +1887,56 @@ public sealed class WorldSession : IDisposable
{
CharacterDeleteAcknowledged?.Invoke();
}
else if (op == CharacterRestore.ResponseOpcode)
else if (op == CharGenVerificationResponse.ResponseOpcode)
{
CharacterRestore.Parsed parsed;
try
{
parsed = CharacterRestore.Parse(body);
}
catch
// Campaign CC CC2: this opcode is a genuine retail reuse
// between CharacterRestore and CharacterCreate responses
// (see CharGenVerificationResponse's doc comment) — the
// awaiting-request latch is the only thing that tells us
// which family a given 0xF643 belongs to. Clear it before
// parsing (not after) so a malformed reply can never leave
// the latch stuck open, awaiting a response that will now
// never come and misattributing whatever arrives next.
PendingCharGenVerificationRequest awaited = _pendingCharGenVerification;
if (awaited == PendingCharGenVerificationRequest.None)
{
if (!_loggedUnexpectedCharGenVerificationResponse)
{
_loggedUnexpectedCharGenVerificationResponse = true;
Console.Error.WriteLine(
"[session] unexpected CharacterGenerationVerificationResponse "
+ "(0xF643) with no outstanding create/restore request — dropped.");
}
continue;
}
CharacterRestoreReceived?.Invoke(parsed);
_pendingCharGenVerification = PendingCharGenVerificationRequest.None;
if (awaited == PendingCharGenVerificationRequest.Restore)
{
CharacterRestore.Parsed parsed;
try
{
parsed = CharacterRestore.Parse(body);
}
catch
{
continue;
}
CharacterRestoreReceived?.Invoke(parsed);
}
else
{
CharGenVerificationResponse.Parsed parsed;
try
{
parsed = CharGenVerificationResponse.Parse(body);
}
catch
{
continue;
}
CharacterCreateResponseReceived?.Invoke(parsed);
}
}
else if (op == CharacterError.Opcode)
{
@ -2223,9 +2325,45 @@ public sealed class WorldSession : IDisposable
/// <summary>
/// Send retail CharacterRestore through the control queue. This is
/// deliberately non-blocking because ACE silently drops unknown guids.
/// Arms the awaiting-request latch as <c>Restore</c> BEFORE the send;
/// the latch correlates the SINGLE outstanding request — a second
/// create/restore sent while this one is outstanding overwrites it, and
/// refusing that overlap is the caller's job (CC3's verification gate).
/// See <see cref="PendingCharGenVerificationRequest"/> (Campaign CC
/// CC2).
/// </summary>
public void SendRestoreCharacter(uint characterId) =>
public void SendRestoreCharacter(uint characterId)
{
_pendingCharGenVerification = PendingCharGenVerificationRequest.Restore;
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
}
/// <summary>
/// Send retail CharacterCreate (opcode <c>0xF656</c>) through the
/// login/logon queue — <c>Proto_UI::SendCharGenResult</c> routes via
/// <c>SendToLogon</c>, the same queue
/// <see cref="SendDeleteCharacter"/> uses (see
/// <see cref="CharacterCreate"/>'s class doc comment). Deliberately
/// non-blocking, matching <see cref="SendRestoreCharacter"/> — ACE
/// silently drops a request whose packed account name doesn't match the
/// session's own account. Arms the awaiting-request latch as
/// <c>Create</c> BEFORE the send; the latch correlates the SINGLE
/// outstanding request — overlap refusal is the caller's job (CC3's
/// verification gate; see
/// <see cref="PendingCharGenVerificationRequest"/>) (Campaign CC CC2).
/// </summary>
public void SendCharacterCreation(
string accountName,
CharacterCreate.Request request,
ReadOnlySpan<uint> skillAdvancementClasses)
{
byte[] body = CharacterCreate.BuildRequestBody(
accountName,
request,
skillAdvancementClasses);
_pendingCharGenVerification = PendingCharGenVerificationRequest.Create;
SendGameMessage(body, GameMessageGroup.LoginQueue);
}
/// <summary>
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
@ -3177,6 +3315,13 @@ public sealed class WorldSession : IDisposable
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
// Campaign CC CC2: a teardown mid-flight must not leave a stale
// Restore/Create latch behind it — this session object is never
// reused (a fresh WorldSession is constructed per connection
// attempt), but clearing here keeps the invariant "no outstanding
// request survives teardown" true rather than merely true-in-practice.
_pendingCharGenVerification = PendingCharGenVerificationRequest.None;
SessionShutdownPlan shutdown = BuildShutdownPlan(
CurrentState,
_transportNegotiated,

View file

@ -44,6 +44,42 @@ public sealed record EnteredWorldStatusEvent : StatusEvent
public required string CharacterName { get; init; }
}
/// <summary>
/// Campaign CC CC2: the Ok reply to an outbound CharacterCreate (opcode
/// <c>0xF656</c>). <see cref="Guid"/>/<see cref="Name"/> mirror the shared
/// <c>0xF643</c> <c>CharGenVerificationResponse</c> Ok identity payload's own
/// field names — deliberately distinct from <see cref="EnteredWorldStatusEvent"/>'s
/// <c>characterId</c>/<c>characterName</c>, since retail logs a freshly
/// created character straight in without a fresh <c>characterList</c>, so
/// this event can precede an <see cref="EnteredWorldStatusEvent"/> for the
/// same character rather than replace it.
/// </summary>
public sealed record CharacterCreatedStatusEvent : StatusEvent
{
public required uint Guid { get; init; }
public required string Name { get; init; }
}
/// <summary>
/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate.
/// <see cref="Code"/> is the raw wire
/// <c>CharGenVerificationResponse.Code</c> value; <see cref="Reason"/> is
/// that code's enum member name (e.g. <c>"NameInUse"</c>);
/// <see cref="Name"/> is the ATTEMPTED character name. The enum member
/// rode the <c>name</c> key until the CC2 review (F4) — same key,
/// different meaning than <c>characterCreated.name</c> — renamed before
/// any consumer shipped.
/// </summary>
public sealed record CreationFailedStatusEvent : StatusEvent
{
public required uint Code { get; init; }
public required string Reason { get; init; }
public required string Name { get; init; }
}
public sealed record PluginLoadedStatusEvent : StatusEvent
{
public required string Plugin { get; init; }

View file

@ -103,6 +103,10 @@ public static class StatusEventParser
ParsePluginFailed(root, v, e, t, sessionId),
"loginCommandFailed" =>
ParseLoginCommandFailed(root, v, e, t, sessionId),
"characterCreated" =>
ParseCharacterCreated(root, v, e, t, sessionId),
"creationFailed" =>
ParseCreationFailed(root, v, e, t, sessionId),
"disconnected" =>
ParseDisconnected(root, v, e, t, sessionId),
"exited" =>
@ -131,6 +135,8 @@ public static class StatusEventParser
"pluginLoaded" or
"pluginFailed" or
"loginCommandFailed" or
"characterCreated" or
"creationFailed" or
"disconnected" or
"exited";
@ -237,6 +243,39 @@ public static class StatusEventParser
CharacterName = RequireString(root, "characterName"),
};
private static StatusEvent ParseCharacterCreated(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new CharacterCreatedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Guid = RequireUInt32(root, "guid"),
Name = RequireString(root, "name"),
};
private static StatusEvent ParseCreationFailed(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new CreationFailedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Code = RequireUInt32(root, "code"),
Reason = RequireString(root, "reason"),
Name = RequireString(root, "name"),
};
private static StatusEvent ParsePluginLoaded(
JsonElement root,
int v,

View file

@ -207,6 +207,53 @@ public sealed class SessionStatusWriter
characterName,
});
/// <summary>
/// Campaign CC CC2: the retail <c>0xF643</c> Ok response to an outbound
/// CharacterCreate — see
/// <c>AcDream.Core.Net.Messages.CharGenVerificationResponse</c>. <paramref name="guid"/>
/// and <paramref name="name"/> come straight off that response's Ok
/// identity payload. This is a distinct event from <see cref="EnteredWorld"/>:
/// retail logs a freshly created character straight in without a fresh
/// CharacterList (see the shared response type's doc comment), so a
/// caller can expect this event to precede an eventual
/// <see cref="EnteredWorld"/> for the same character, not replace it.
/// </summary>
public void CharacterCreated(string sessionId, uint guid, string name) =>
Write(new
{
v = VocabularyVersion,
e = "characterCreated",
t = Now(),
sessionId,
guid,
name,
});
/// <summary>
/// Campaign CC CC2: a non-Ok <c>0xF643</c> response to an outbound
/// CharacterCreate. <paramref name="code"/> is the raw wire
/// <c>CharGenVerificationResponse.Code</c> value; <paramref name="reason"/>
/// is that code's enum member name (e.g. <c>"NameInUse"</c>) so a
/// launcher can render a readable reason without hard-coding the
/// server's numeric-to-dialog mapping itself; <paramref name="name"/>
/// is the ATTEMPTED character name — the thing a launcher most wants to
/// show ("the name Bob is taken"). The key was <c>name</c> for the enum
/// member until the CC2 review (F4): <c>characterCreated.name</c> is a
/// character name, and one status vocabulary must not give the same key
/// two meanings. Renamed before any consumer shipped.
/// </summary>
public void CreationFailed(string sessionId, uint code, string reason, string name) =>
Write(new
{
v = VocabularyVersion,
e = "creationFailed",
t = Now(),
sessionId,
code,
reason,
name,
});
public void PluginLoaded(string sessionId, string plugin) =>
Write(new
{