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

File diff suppressed because one or more lines are too long

View file

@ -170,6 +170,7 @@ line, writer opens `FileShare.Read`, tailer opens
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
`pluginFailed{plugin,error}`,
`loginCommandFailed{commandIndex,command,error}`,
`characterCreated{guid,name}`, `creationFailed{code,reason,name}`,
`disconnected{reason}`,
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
@ -177,6 +178,26 @@ sides. Unknown `e` values must parse to a typed Unknown event, never
throw; a known `e` with a wrong payload shape should be distinguishable
from an unknown `e` (LA3 review finding 12).
**Campaign CC CC2 amendment (this section is the contract; the writer and
tailer below implement it, in that order):** `characterCreated{guid,name}`
fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request —
`guid`/`name` come straight off the shared `0xF643`
`CharGenVerificationResponse` Ok identity payload
(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately
named `guid`/`name` rather than `characterId`/`characterName` to mirror
that payload's own field names and to read distinctly from
`enteredWorld` — a freshly created character is logged straight in by
retail without a fresh `characterList` (see that type's doc comment), so
`characterCreated` can precede an `enteredWorld` for the same character
rather than replacing it. `creationFailed{code,reason,name}` fires on any
non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code`
value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a
reader gets a stable readable reason without hard-coding the numeric
mapping itself, and `name` is the ATTEMPTED character name so a launcher
can render "the name Bob is taken". (CC2 review F4: the enum member
originally rode the `name` key, colliding in meaning with
`characterCreated.name`; renamed before any consumer shipped.)
`loginCommandFailed.commandIndex` is the zero-based index in the configured
`loginCommands` array. `command` is the exact configured line and `error` is
the isolated parser/router/handler failure. The event is observational: the
@ -214,7 +235,8 @@ Three pieces, one slice, because they share the session-config/status seam:
config; absent → permanent no-op sink). Versioned event
vocabulary (`"v":1`): `started`, `connected`, `characterList`,
`enteredWorld`, `pluginLoaded`/`pluginFailed`,
`loginCommandFailed`, `disconnected`, `exited`.
`loginCommandFailed`, `characterCreated`/`creationFailed` (Campaign CC
CC2), `disconnected`, `exited`.
Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL
sink with four kinds (lifecycle/failure/event/resources) and NO per-session
file — the status writer is a second, separate sink, not a rework of the

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
{

View file

@ -0,0 +1,110 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign CC CC2: the shared 0xF643 parser both CharacterRestore and
/// CharacterCreate responses consume. See <see cref="CharacterRestoreTests"/>
/// for the pre-existing CharacterRestore-shaped coverage that must survive
/// this type's promotion unchanged.
/// </summary>
public sealed class CharGenVerificationResponseTests
{
[Fact]
public void Parse_Ok_PopulatesIdentityPayload()
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write((uint)CharGenVerificationResponse.Code.Ok)
.WriteGuid(0x5000000Bu)
.WriteString16L("+NewChar")
.Write(0u);
CharGenVerificationResponse.Parsed parsed =
CharGenVerificationResponse.Parse(w.ToArray());
Assert.Equal(1u, parsed.RawCode);
Assert.Equal(CharGenVerificationResponse.Code.Ok, parsed.AsCode);
Assert.True(parsed.IsOk);
Assert.Equal(0x5000000Bu, parsed.Guid);
Assert.Equal("+NewChar", parsed.Name);
Assert.Equal(0u, parsed.SecondsGreyedOut);
}
[Theory]
[InlineData(0u, CharGenVerificationResponse.Code.Undef)]
[InlineData(2u, CharGenVerificationResponse.Code.Pending)]
[InlineData(3u, CharGenVerificationResponse.Code.NameInUse)]
[InlineData(4u, CharGenVerificationResponse.Code.NameBanned)]
[InlineData(5u, CharGenVerificationResponse.Code.Corrupt)]
[InlineData(6u, CharGenVerificationResponse.Code.DatabaseDown)]
[InlineData(7u, CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
public void Parse_EveryNonOkCode_IsFlagOnlyWithNullTrailingFields(
uint rawCode,
CharGenVerificationResponse.Code expectedCode)
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(rawCode);
CharGenVerificationResponse.Parsed parsed =
CharGenVerificationResponse.Parse(w.ToArray());
Assert.Equal(rawCode, parsed.RawCode);
Assert.Equal(expectedCode, parsed.AsCode);
Assert.False(parsed.IsOk);
Assert.Null(parsed.Guid);
Assert.Null(parsed.Name);
Assert.Null(parsed.SecondsGreyedOut);
}
[Fact]
public void Parse_UnknownCode_NeverThrowsOnTheCast()
{
// A plain enum cast never throws in C# — a private-server or future
// retail revision sending a code we haven't named yet must not crash
// the parser.
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(99u);
CharGenVerificationResponse.Parsed parsed =
CharGenVerificationResponse.Parse(w.ToArray());
Assert.Equal(99u, parsed.RawCode);
Assert.Equal((CharGenVerificationResponse.Code)99u, parsed.AsCode);
Assert.False(parsed.IsOk);
}
[Fact]
public void Parse_WrongOpcode_Throws()
{
byte[] bytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
Assert.Throws<FormatException>(() => CharGenVerificationResponse.Parse(bytes));
}
[Fact]
public void Parse_TruncatedAfterCode_Throws()
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write((uint)CharGenVerificationResponse.Code.Ok);
Assert.Throws<FormatException>(() => CharGenVerificationResponse.Parse(w.ToArray()));
}
[Fact]
public void Parse_TruncatedBeforeCode_Throws()
{
var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode);
Assert.Throws<FormatException>(() => CharGenVerificationResponse.Parse(w.ToArray()));
}
[Fact]
public void ResponseOpcode_MatchesCharacterRestoresResponseOpcode()
{
// The whole point of this type: both families collide on the exact
// same wire opcode.
Assert.Equal(CharacterRestore.ResponseOpcode, CharGenVerificationResponse.ResponseOpcode);
}
}

View file

@ -0,0 +1,308 @@
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign CC CC2: byte-exact coverage for the outbound CharacterCreate
/// (0xF656) builder — field order, the 55-slot skill-advancement invariant,
/// and the trailing checksum's exact retail accumulation set (see
/// <see cref="CharacterCreate"/>'s class doc comment for the decompiled
/// source of truth).
/// </summary>
public sealed class CharacterCreateTests
{
private static uint[] MakeSkills(uint seed = 0)
{
var skills = new uint[CharacterCreate.SkillAdvancementClassCount];
for (int i = 0; i < skills.Length; i++)
skills[i] = seed + (uint)i;
return skills;
}
private static CharacterCreate.Request MakeRequest() => new(
Heritage: 1u,
Gender: 0u,
Appearance: new CharacterCreate.Appearance(
EyesStrip: 2u,
NoseStrip: 3u,
MouthStrip: 4u,
HairColor: 5u,
EyeColor: 6u,
HairStyle: 7u,
HeadgearStyle: 8u,
HeadgearColor: 9u,
ShirtStyle: 10u,
ShirtColor: 11u,
TrousersStyle: 12u,
TrousersColor: 13u,
FootwearStyle: 14u,
FootwearColor: 15u,
SkinShade: 0.1,
HairShade: 0.2,
HeadgearShade: 0.3,
ShirtShade: 0.4,
TrousersShade: 0.5,
FootwearShade: 0.6),
Template: 16u,
Attributes: new CharacterCreate.Attributes(
Strength: 17u,
Endurance: 18u,
Coordination: 19u,
Quickness: 20u,
Focus: 21u,
Self: 22u),
Slot: 0u,
ClassId: 1u,
Name: "Testcdream",
StartArea: 23u,
IsAdmin: false,
IsEnvoy: false);
[Fact]
public void BuildRequestBody_Layout_MatchesRetailCGPackFieldOrder()
{
CharacterCreate.Request request = MakeRequest();
uint[] skills = MakeSkills();
byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, skills);
int pos = 0;
uint ReadU32()
{
uint v = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
pos += 4;
return v;
}
double ReadF64()
{
double v = BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(pos));
pos += 8;
return v;
}
string ReadString16L()
{
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
pos += 2;
string s = Encoding.ASCII.GetString(body, pos, len);
pos += len;
int recordSize = 2 + len;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return s;
}
Assert.Equal(CharacterCreate.Opcode, ReadU32());
Assert.Equal("testaccount", ReadString16L());
Assert.Equal(1u, ReadU32()); // CG_Pack@0x005c7208 constant
Assert.Equal(request.Heritage, ReadU32());
Assert.Equal(request.Gender, ReadU32());
Assert.Equal(request.Appearance.EyesStrip, ReadU32());
Assert.Equal(request.Appearance.NoseStrip, ReadU32());
Assert.Equal(request.Appearance.MouthStrip, ReadU32());
Assert.Equal(request.Appearance.HairColor, ReadU32());
Assert.Equal(request.Appearance.EyeColor, ReadU32());
Assert.Equal(request.Appearance.HairStyle, ReadU32());
Assert.Equal(request.Appearance.HeadgearStyle, ReadU32());
Assert.Equal(request.Appearance.HeadgearColor, ReadU32());
Assert.Equal(request.Appearance.ShirtStyle, ReadU32());
Assert.Equal(request.Appearance.ShirtColor, ReadU32());
Assert.Equal(request.Appearance.TrousersStyle, ReadU32());
Assert.Equal(request.Appearance.TrousersColor, ReadU32());
Assert.Equal(request.Appearance.FootwearStyle, ReadU32());
Assert.Equal(request.Appearance.FootwearColor, ReadU32());
Assert.Equal(request.Appearance.SkinShade, ReadF64());
Assert.Equal(request.Appearance.HairShade, ReadF64());
Assert.Equal(request.Appearance.HeadgearShade, ReadF64());
Assert.Equal(request.Appearance.ShirtShade, ReadF64());
Assert.Equal(request.Appearance.TrousersShade, ReadF64());
Assert.Equal(request.Appearance.FootwearShade, ReadF64());
Assert.Equal(request.Template, ReadU32());
Assert.Equal(request.Attributes.Strength, ReadU32());
Assert.Equal(request.Attributes.Endurance, ReadU32());
Assert.Equal(request.Attributes.Coordination, ReadU32());
Assert.Equal(request.Attributes.Quickness, ReadU32());
Assert.Equal(request.Attributes.Focus, ReadU32());
Assert.Equal(request.Attributes.Self, ReadU32());
Assert.Equal(request.Slot, ReadU32());
Assert.Equal(request.ClassId, ReadU32());
uint numSkills = ReadU32();
Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, numSkills);
for (int i = 0; i < skills.Length; i++)
Assert.Equal(skills[i], ReadU32());
Assert.Equal(request.Name, ReadString16L());
Assert.Equal(request.StartArea, ReadU32());
Assert.Equal(0u, ReadU32()); // isAdmin
Assert.Equal(0u, ReadU32()); // isEnvoy
uint checksum = ReadU32();
Assert.Equal(CharacterCreate.ComputeChecksum(request), checksum);
Assert.Equal(pos, body.Length);
}
[Fact]
public void BuildRequestBody_ExactByteSequence_ShortAccountAndName()
{
// Minimal fixture with distinct short strings, hand-checked padding.
CharacterCreate.Request request = new(
Heritage: 1u,
Gender: 0u,
Appearance: default,
Template: 0u,
Attributes: default,
Slot: 0u,
ClassId: 1u,
Name: "ab",
StartArea: 0u,
IsAdmin: false,
IsEnvoy: false);
uint[] skills = new uint[CharacterCreate.SkillAdvancementClassCount];
byte[] body = CharacterCreate.BuildRequestBody("cd", request, skills);
int pos = 0;
Assert.Equal(CharacterCreate.Opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
// String16L("cd") = u16(2) + 2 bytes, already 4-byte aligned.
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2;
Assert.Equal("cd", Encoding.ASCII.GetString(body, pos, 2)); pos += 2;
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // constant
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // heritage
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // gender
// 14 appearance strip/color u32 fields, all zero (default).
pos += 14 * 4;
// 6 f64 shades, all zero (default).
pos += 6 * 8;
pos += 4; // template
pos += 6 * 4; // attributes
pos += 4; // slot
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // classId
Assert.Equal(
(uint)CharacterCreate.SkillAdvancementClassCount,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)));
pos += 4;
pos += CharacterCreate.SkillAdvancementClassCount * 4;
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2;
Assert.Equal("ab", Encoding.ASCII.GetString(body, pos, 2)); pos += 2;
pos += 4; // startArea
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isAdmin
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isEnvoy
// Checksum: heritage(1) + gender(0) + 3 strips(0) + hairColor(0) +
// eyeColor(0) + hairStyle(0) + headgearStyle(0) + shirtStyle(0) +
// trousersStyle(0) + footwearStyle(0) + template(0) + 6 attrs(0) = 1.
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
Assert.Equal(pos, body.Length);
}
[Fact]
public void ComputeChecksum_ExactRetailAccumulationSet()
{
// CG_Pack@0x005c7213-0x005c74c3: heritage, gender, the three
// appearance strips, hairColor, eyeColor, hairStyle, headgearStyle,
// shirtStyle, trousersStyle, footwearStyle, template, and the six
// attributes — nineteen terms, u32 wraparound addition.
CharacterCreate.Request request = MakeRequest();
uint expected = unchecked(
request.Heritage
+ request.Gender
+ request.Appearance.EyesStrip
+ request.Appearance.NoseStrip
+ request.Appearance.MouthStrip
+ request.Appearance.HairColor
+ request.Appearance.EyeColor
+ request.Appearance.HairStyle
+ request.Appearance.HeadgearStyle
+ request.Appearance.ShirtStyle
+ request.Appearance.TrousersStyle
+ request.Appearance.FootwearStyle
+ request.Template
+ request.Attributes.Strength
+ request.Attributes.Endurance
+ request.Attributes.Coordination
+ request.Attributes.Quickness
+ request.Attributes.Focus
+ request.Attributes.Self);
Assert.Equal(expected, CharacterCreate.ComputeChecksum(request));
// Concretely: 1+0+2+3+4+5+6+7+8+10+12+14+16+17+18+19+20+21+22 = 205.
Assert.Equal(205u, expected);
}
[Fact]
public void ComputeChecksum_ExcludesColorFieldsShadesSlotAndClassId()
{
// These fields sit adjacent to summed fields on the wire but the
// decompiled CG_Pack accumulation (0x005c7213-0x005c74c3) never
// touches them — mutating only these must not move the checksum.
CharacterCreate.Request baseline = MakeRequest();
uint baselineChecksum = CharacterCreate.ComputeChecksum(baseline);
CharacterCreate.Request mutated = baseline with
{
Appearance = baseline.Appearance with
{
HeadgearColor = baseline.Appearance.HeadgearColor + 1000u,
ShirtColor = baseline.Appearance.ShirtColor + 1000u,
TrousersColor = baseline.Appearance.TrousersColor + 1000u,
FootwearColor = baseline.Appearance.FootwearColor + 1000u,
SkinShade = baseline.Appearance.SkinShade + 5.0,
HairShade = baseline.Appearance.HairShade + 5.0,
},
Slot = baseline.Slot + 7u,
ClassId = baseline.ClassId + 7u,
};
Assert.Equal(baselineChecksum, CharacterCreate.ComputeChecksum(mutated));
}
[Fact]
public void BuildRequestBody_SkillCountOtherThan55_Throws()
{
CharacterCreate.Request request = MakeRequest();
Assert.Throws<ArgumentException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills().AsSpan(0, 54)));
Assert.Throws<ArgumentException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, new uint[56]));
Assert.Throws<ArgumentException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, ReadOnlySpan<uint>.Empty));
}
[Fact]
public void BuildRequestBody_NullAccountName_Throws()
{
CharacterCreate.Request request = MakeRequest();
Assert.Throws<ArgumentNullException>(() =>
CharacterCreate.BuildRequestBody(null!, request, MakeSkills()));
}
[Fact]
public void BuildRequestBody_NullCharacterName_Throws()
{
CharacterCreate.Request request = MakeRequest() with { Name = null! };
Assert.Throws<ArgumentNullException>(() =>
CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills()));
}
[Fact]
public void BuildRequestBody_AdminAndEnvoyFlags_EncodeAsOneOrZero()
{
CharacterCreate.Request request = MakeRequest() with { IsAdmin = true, IsEnvoy = true };
byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills());
// isAdmin and isEnvoy are the two u32s immediately before the
// trailing checksum.
uint isEnvoy = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 8));
uint isAdmin = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 12));
Assert.Equal(1u, isAdmin);
Assert.Equal(1u, isEnvoy);
}
}

View file

@ -0,0 +1,330 @@
using System.Net;
using System.Reflection;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Tests.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Campaign CC CC2: the awaiting-request latch that disambiguates the two
/// message families sharing opcode 0xF643 (see
/// <see cref="CharGenVerificationResponse"/>'s doc comment) — create-then-
/// response routes to the create event, a plain restore is unaffected, an
/// unexpected/uncorrelated response is dropped rather than misattributed,
/// and teardown clears the latch. See
/// <see cref="WorldSessionCharacterSelectionTests"/> for the general
/// character-management wire-order coverage this file complements.
/// </summary>
public sealed class WorldSessionCharacterCreationTests
{
private sealed class NullTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromCanceled<NetReceiveResult>(cancellationToken);
public void Dispose() { }
}
private static WorldSession CreateSession() =>
new(
new IPEndPoint(IPAddress.Loopback, 9000),
new NullTransport());
private static CharacterCreate.Request MakeCreateRequest() => new(
Heritage: 1u,
Gender: 0u,
Appearance: default,
Template: 0u,
Attributes: default,
Slot: 0u,
ClassId: 1u,
Name: "NewChar",
StartArea: 0u,
IsAdmin: false,
IsEnvoy: false);
private static byte[] BuildVerificationResponseBody(uint code, uint guid, string name) =>
code == (uint)CharGenVerificationResponse.Code.Ok
? AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(code)
.WriteGuid(guid)
.WriteString16L(name)
.Write(0u)
.ToArray()
: AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
.Write(code)
.ToArray();
private static byte[] BuildPacket(params byte[][] messages)
{
int length = messages.Sum(message =>
MessageFragmentHeader.Size + message.Length);
var fragments = new byte[length];
int position = 0;
uint sequence = 1u;
foreach (byte[] message in messages)
{
position += GameMessageFragment.WriteSingleFragment(
fragments.AsSpan(position),
sequence++,
GameMessageGroup.UIQueue,
message);
}
return PacketCodec.Encode(
new PacketHeader
{
Sequence = 1u,
Flags = PacketHeaderFlags.BlobFragments,
},
fragments,
outboundIsaac: null);
}
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{
MethodInfo method = typeof(WorldSession).GetMethod(
"ProcessDatagram",
BindingFlags.NonPublic | BindingFlags.Instance)!;
method.Invoke(session, [new ReadOnlyMemory<byte>(datagram), null, true]);
}
private static PendingLatch ReadPendingLatch(WorldSession session)
{
FieldInfo field = typeof(WorldSession).GetField(
"_pendingCharGenVerification",
BindingFlags.NonPublic | BindingFlags.Instance)!;
return (PendingLatch)field.GetValue(session)!;
}
// Mirrors WorldSession's private PendingCharGenVerificationRequest enum
// by name/ordinal — read via reflection above so the test doesn't need
// InternalsVisibleTo for a single private enum.
private enum PendingLatch { None, Restore, Create }
[Fact]
public void SendCharacterCreation_ThenOkResponse_RoutesToCreateEventNotRestore()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
var createEvents = new List<CharGenVerificationResponse.Parsed>();
var restoreEvents = new List<CharacterRestore.Parsed>();
session.CharacterCreateResponseReceived += createEvents.Add;
session.CharacterRestoreReceived += restoreEvents.Add;
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000010u,
"NewChar"));
InvokeProcessDatagram(session, packet);
CharGenVerificationResponse.Parsed created = Assert.Single(createEvents);
Assert.True(created.IsOk);
Assert.Equal(0x50000010u, created.Guid);
Assert.Equal("NewChar", created.Name);
Assert.Empty(restoreEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void SendCharacterCreation_ThenFailureResponse_RoutesToCreateEventWithNullIdentity()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
CharGenVerificationResponse.Parsed? created = null;
session.CharacterCreateResponseReceived += parsed => created = parsed;
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.NameInUse,
guid: 0u,
name: string.Empty));
InvokeProcessDatagram(session, packet);
Assert.NotNull(created);
Assert.Equal(CharGenVerificationResponse.Code.NameInUse, created!.Value.AsCode);
Assert.False(created.Value.IsOk);
Assert.Null(created.Value.Guid);
}
[Fact]
public void SendRestoreCharacter_ThenResponse_StillRoutesToRestoreEvent()
{
// Regression guard: the correlation latch must not break the
// pre-existing restore-only flow that predates Campaign CC.
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000001u,
"Restored"));
InvokeProcessDatagram(session, packet);
CharacterRestore.Parsed restored = Assert.Single(restoreEvents);
Assert.Equal(0x50000001u, restored.Guid);
Assert.Equal("Restored", restored.Name);
Assert.Empty(createEvents);
}
/// <summary>
/// CC2 review F1: pins the latch's stated scope EXACTLY. The latch
/// correlates the single outstanding request and does NOT refuse
/// overlap — a second send while one is outstanding OVERWRITES it, so
/// the first request's reply is delivered to the second request's
/// event. Refusing overlap is the caller's job (CC3's Runtime
/// verification gate, mirroring retail's DoFinish UNDEF-state gate).
/// If CC3 (or anyone) changes this transport-level behavior, this test
/// must change WITH it, deliberately.
/// </summary>
[Fact]
public void OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest()
{
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
Assert.Equal(PendingLatch.Create, ReadPendingLatch(session));
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
// This reply is semantically the RESTORE's — but the overwritten
// latch routes it to the create event. That is the documented
// overwrite behavior, pinned here.
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000001u,
"Restored"));
InvokeProcessDatagram(session, packet);
Assert.Empty(restoreEvents);
Assert.Single(createEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed()
{
using WorldSession session = CreateSession();
var restoreEvents = new List<CharacterRestore.Parsed>();
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterRestoreReceived += restoreEvents.Add;
session.CharacterCreateResponseReceived += createEvents.Add;
// No SendRestoreCharacter / SendCharacterCreation call precedes this
// — the latch is None.
byte[] packet = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok,
0x50000099u,
"Stray"));
InvokeProcessDatagram(session, packet);
Assert.Empty(restoreEvents);
Assert.Empty(createEvents);
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void SecondResponse_AfterFirstAlreadyConsumed_IsDroppedNotMisattributed()
{
// A create request is satisfied; a SECOND, uncorrelated 0xF643
// arriving afterward (e.g. a stray/replayed packet) must not be
// misread as a reply to anything.
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[CharacterCreate.SkillAdvancementClassCount]);
var createEvents = new List<CharGenVerificationResponse.Parsed>();
session.CharacterCreateResponseReceived += createEvents.Add;
byte[] first = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok, 0x50000010u, "NewChar"));
InvokeProcessDatagram(session, first);
Assert.Single(createEvents);
byte[] second = BuildPacket(
BuildVerificationResponseBody(
(uint)CharGenVerificationResponse.Code.Ok, 0x50000011u, "Stray"));
InvokeProcessDatagram(session, second);
// Still exactly one — the second reply was dropped, not appended.
Assert.Single(createEvents);
}
[Fact]
public void Dispose_ClearsTheOutstandingLatch()
{
WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
Assert.Equal(PendingLatch.Restore, ReadPendingLatch(session));
session.Dispose();
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
[Fact]
public void BuildRequestBody_InvalidSkillCount_DoesNotArmTheLatch()
{
// The latch is armed AFTER the body is built (SendCharacterCreation
// builds first), so a builder-level throw (wrong skill count) must
// leave no outstanding request behind — nothing was actually sent.
using WorldSession session = CreateSession();
session.GameMessageCapture = (_, _) => { };
Assert.Throws<ArgumentException>(() =>
session.SendCharacterCreation(
"testaccount",
MakeCreateRequest(),
new uint[10]));
Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
}
}

View file

@ -61,6 +61,13 @@ public sealed class WorldSessionCharacterSelectionTests
public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters()
{
using var session = CreateSession();
// Campaign CC CC2: a restore response only dispatches when the
// session actually has an outstanding restore request armed — see
// WorldSessionCharacterCreationTests for the correlation-specific
// coverage (create routing, no-outstanding drop, teardown clears).
session.GameMessageCapture = (_, _) => { };
session.SendRestoreCharacter(0x50000001u);
var events = new List<string>();
session.CharacterListReceived += roster =>
events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}");

View file

@ -77,6 +77,37 @@ public sealed class StatusEventParserTests
Assert.Equal("boom", failed.Error);
}
[Fact]
public void ParsesCharacterCreatedAndCreationFailed()
{
var created = Assert.IsType<CharacterCreatedStatusEvent>(
StatusEventParser.Parse(
"""{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}"""));
Assert.Equal(1342177296u, created.Guid);
Assert.Equal("NewChar", created.Name);
var failed = Assert.IsType<CreationFailedStatusEvent>(
StatusEventParser.Parse(
"""{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}"""));
Assert.Equal(3u, failed.Code);
Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("Bob", failed.Name);
}
[Theory]
[InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NewChar\"}")]
[InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"guid\":1342177296}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"reason\":\"NameInUse\",\"name\":\"Bob\"}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"name\":\"Bob\"}")]
[InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"reason\":\"NameInUse\"}")]
public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line)
{
var malformed = Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));
Assert.Equal("s1", malformed.SessionId);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
[Fact]
public void ParsesLoginCommandFailed()
{

View file

@ -183,6 +183,34 @@ public sealed class StatusFileTailerTests : IDisposable
Assert.Empty(events);
}
/// <summary>
/// Campaign CC CC2: the two creation-flow events round-trip through the
/// actual file-tailing pipeline (not just <see cref="StatusEventParser"/>
/// in isolation) — matching the exact camelCase shape
/// <c>AcDream.Runtime.Session.SessionStatusWriter</c> writes.
/// </summary>
[Fact]
public void TailsCharacterCreatedAndCreationFailedEvents()
{
AppendShared(
"""{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}"""
+ "\n"
+ """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}"""
+ "\n");
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Equal(2, events.Count);
var created = Assert.IsType<CharacterCreatedStatusEvent>(events[0]);
Assert.Equal(1342177296u, created.Guid);
Assert.Equal("NewChar", created.Name);
var failed = Assert.IsType<CreationFailedStatusEvent>(events[1]);
Assert.Equal(3u, failed.Code);
Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("Bob", failed.Name);
}
[Fact]
public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced()
{

View file

@ -109,6 +109,8 @@ public sealed class SessionStatusWriterTests
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "failed");
writer.LoginCommandFailed("s1", 0, "", "unknown command");
writer.CharacterCreated("s1", 0x50000001u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse", "Bob");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@ -116,6 +118,43 @@ public sealed class SessionStatusWriterTests
Assert.False(File.Exists(file.Path));
}
/// <summary>
/// Campaign CC CC2: pins the exact shape of the two new creation-flow
/// status events, added to the LA1 vocabulary alongside
/// <c>CharacterCreate</c> (opcode 0xF656) — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> §LA1's amended
/// status-vocabulary text.
/// </summary>
[Fact]
public void CharacterCreatedAndCreationFailed_WriteThePinnedShape()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.CharacterCreated("s1", 0x50000010u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse", "Bob");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(2, lines.Length);
JsonElement created = Parse(lines[0]);
Assert.Equal(1, created.GetProperty("v").GetInt32());
Assert.Equal("characterCreated", created.GetProperty("e").GetString());
Assert.Equal("s1", created.GetProperty("sessionId").GetString());
Assert.Equal(0x50000010u, created.GetProperty("guid").GetUInt32());
Assert.Equal("NewChar", created.GetProperty("name").GetString());
AssertExactProperties(lines[0], "v", "e", "t", "sessionId", "guid", "name");
JsonElement failed = Parse(lines[1]);
Assert.Equal("creationFailed", failed.GetProperty("e").GetString());
Assert.Equal("s1", failed.GetProperty("sessionId").GetString());
Assert.Equal(3u, failed.GetProperty("code").GetUInt32());
Assert.Equal("NameInUse", failed.GetProperty("reason").GetString());
Assert.Equal("Bob", failed.GetProperty("name").GetString());
AssertExactProperties(
lines[1], "v", "e", "t", "sessionId", "code", "reason", "name");
}
[Fact]
public void BlankPathIsTreatedAsAbsent()
{