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 rounde77ebf10(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 fix95e95bb6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
55fc51ed8c
17 changed files with 1660 additions and 41 deletions
152
src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
Normal file
152
src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
319
src/AcDream.Core.Net/Messages/CharacterCreate.cs
Normal file
319
src/AcDream.Core.Net/Messages/CharacterCreate.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue