diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md
index c4040ae6..19e08478 100644
--- a/docs/plans/2026-08-14-launcher-campaign.md
+++ b/docs/plans/2026-08-14-launcher-campaign.md
@@ -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,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,23 @@ 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,name}` fires on any non-Ok
+reply: `code` is the raw wire `CharGenVerificationResponse.Code` value,
+`name` 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.
+
`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 +232,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
diff --git a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
new file mode 100644
index 00000000..b506c2bd
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
@@ -0,0 +1,145 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Shared parser for opcode 0xF643 — retail's
+/// CharacterGenerationVerificationResponse shape, which BOTH
+/// (opcode 0xF7D9 request) and
+/// (opcode 0xF656 request) receive on
+/// the exact same wire opcode — a genuine retail opcode reuse, confirmed by
+/// ACE's own GameMessageOpcode.cs declaring both
+/// CharacterCreateResponse = 0xF643 and
+/// CharacterRestoreResponse = 0xF643, // This is a duplicate....
+///
+///
+/// Campaign CC CC2: this type is the promotion of the parse logic
+/// that used to live only in (Campaign
+/// LA slice LA7a). Character creation now exists (),
+/// so the two message families that collide on this opcode are both real and
+/// both need it — keeps its own
+/// shape for source compatibility and
+/// delegates to this type internally; new code (the create response,
+/// WorldSession.CharacterCreateResponseReceived) consumes
+/// directly. A caller cannot tell "restore response"
+/// from "create response" by opcode or shape alone — WorldSession
+/// disambiguates by tracking which outbound request (restore vs. create) it
+/// is awaiting a reply to (see WorldSession's awaiting-request latch).
+///
+///
+///
+/// Wire layout, verbatim from ACE's GameMessageCharacterCreateResponse.cs
+/// / GameMessageCharacterRestore.cs (both write the identical shape)
+/// and cross-checked against holtburger's
+/// CharacterCreateResponseData::unpack
+/// (holtburger-protocol/src/messages/character/types.rs:379-410):
+///
+///
+///
+/// u32 opcode (0xF643)
+/// u32 code (CharacterGenerationVerificationResponse)
+/// -- only when code == Ok --
+/// u32 guid
+/// String16L name
+/// u32 secondsGreyedOut
+///
+///
+///
+/// is a verbatim port of ACE's
+/// CharacterGenerationVerificationResponse enum
+/// (ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs),
+/// which is itself retail's own dialog dispatch table
+/// (Handle_CharGenVerificationResponse@0x0055E8B0): NameInUse →
+/// ID_Character_Err_NameReserved, NameBanned →
+/// ID_Character_Err_NameBanned, Corrupt/DatabaseDown →
+/// ID_Character_Err_NameDBDown, AdminPrivilegeDenied →
+/// ID_Character_Err_NameAdminDenied. Pending/Undef
+/// retail treats as a silent state reset with no dialog — notably ACE sends
+/// Pending for a disabled-Olthoi rejection
+/// (CharacterHandler.CharacterCreateEx,
+/// olthoi_play_disabled 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.
+///
+///
+public static class CharGenVerificationResponse
+{
+ public const uint ResponseOpcode = 0xF643u;
+
+ ///
+ /// Verbatim port of ACE's CharacterGenerationVerificationResponse
+ /// enum, which is retail's own Handle_CharGenVerificationResponse
+ /// dispatch table.
+ ///
+ public enum Code : uint
+ {
+ Undef = 0,
+ Ok = 1,
+ Pending = 2,
+ NameInUse = 3,
+ NameBanned = 4,
+ Corrupt = 5,
+ DatabaseDown = 6,
+ AdminPrivilegeDenied = 7,
+ }
+
+ ///
+ /// Parsed 0xF643 body. , , and
+ /// are only populated when
+ /// equals — retail omits
+ /// them entirely on the wire otherwise (both
+ /// GameMessageCharacterCreateResponse and
+ /// GameMessageCharacterRestore gate the trailing fields on
+ /// response == ... .Ok).
+ ///
+ public readonly record struct Parsed(
+ uint RawCode,
+ uint? Guid,
+ string? Name,
+ uint? SecondsGreyedOut)
+ {
+ ///
+ /// Best-effort named view of . A plain enum
+ /// cast never throws in C#, so this is safe even for a value retail
+ /// never defined — always trust as the source
+ /// of truth.
+ ///
+ public Code AsCode => (Code)RawCode;
+
+ /// True when the trailing identity fields are present.
+ public bool IsOk => RawCode == (uint)Code.Ok;
+ }
+
+ ///
+ /// Parse a 0xF643 body. must start with
+ /// the 4-byte opcode.
+ ///
+ public static Parsed Parse(ReadOnlySpan 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 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;
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/CharacterCreate.cs b/src/AcDream.Core.Net/Messages/CharacterCreate.cs
new file mode 100644
index 00000000..ca008060
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CharacterCreate.cs
@@ -0,0 +1,305 @@
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Retail character-creation request (opcode 0xF656). Campaign CC
+/// slice CC2 — the outbound half of retail creation; the shared 0xF643
+/// response is (see that type's doc
+/// comment for the two-family opcode collision with
+/// , and WorldSession's awaiting-request
+/// latch for how the two are disambiguated on receipt).
+///
+///
+/// Wire layout ported byte-for-byte from
+/// Proto_UI::SendCharGenResult@0x00546a70 (packs the account name,
+/// then calls ACCharGenResult::Pack@0x005c7570 →
+/// ACCharGenResult::CG_Pack@0x005c7200) and cross-checked against
+/// ACE's CharacterCreateInfo.Unpack / Appearance.Unpack
+/// (ACE.Entity/CharacterCreateInfo.cs, ACE.Entity/Appearance.cs)
+/// and holtburger's CharacterCreateRequestData
+/// (holtburger-protocol/src/messages/character/types.rs:236-369),
+/// which agree on every field and its order:
+///
+///
+///
+/// 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 )
+/// u32[] skillAdvancementClasses (numSkills entries)
+/// String16L name
+/// u32 startArea
+/// u32 isAdmin
+/// u32 isEnvoy (ACE: IsSentinel)
+/// u32 checksum (see )
+///
+///
+///
+/// The 55-slot invariant. ACE's PlayerFactory.Create
+/// (reached from CharacterHandler.CharacterCreateEx) rejects a
+/// client/server skill-table mismatch by TERMINATING the session
+/// (PlayerFactory.CreateResult.ClientServerSkillsMismatch →
+/// session.Terminate(SessionTerminationReason.ClientVersionIncorrect, ...))
+/// — there is no graceful recovery from sending the wrong count. Retail's
+/// live skill table has exactly
+/// (55) skills, so takes
+/// skillAdvancementClasses as a and
+/// throws for any length other than 55 —
+/// structurally impossible to send anything else through this builder.
+///
+///
+///
+/// The trailing checksum. Retail computes and sends it
+/// (CG_Pack@0x005c74c3, the final *(uint32_t*)ecx_33 =
+/// (ebx_18 + self) store); ACE's CharacterCreateInfo.Unpack never
+/// reads it (the reader consumes isSentinel and stops — see
+/// ACE.Entity/CharacterCreateInfo.cs:67) and holtburger's
+/// CharacterCreateRequestData::unpack agrees (its field list ends at
+/// is_sentinel, no checksum read). We compute and send it anyway for
+/// byte fidelity with a genuine retail client. Decompiled accumulation
+/// order (CG_Pack@0x005c7213-0x005c74c3) 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 — 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;
+/// orders the terms for readability, not
+/// wire fidelity.
+///
+///
+///
+/// Routing. Proto_UI::SendCharGenResult sends via
+/// Proto_UI::SendToLogon@0x00546b03 — the SAME queue as
+/// 's request
+/// (Proto_UI::SendDeleteCharacter@0x00546b83, also SendToLogon)
+/// and CharacterEnterWorld's request
+/// (Proto_UI::SendEnterWorld@0x00546c12). WorldSession's outbound
+/// helper, SendCharacterCreation, sends on
+/// GameMessageGroup.LoginQueue — the same queue
+/// WorldSession.SendDeleteCharacter already uses.
+///
+///
+///
+/// Account-name gate. ACE's CharacterCreate handler
+/// (CharacterHandler.cs:27-32) silently drops the request when the
+/// packed account name doesn't match session.Account — the same
+/// silent-no-reply shape 's doc comment already
+/// warns about for restore. WorldSession's awaiting-request latch
+/// must never assume a reply is coming.
+///
+///
+public static class CharacterCreate
+{
+ public const uint Opcode = 0xF656u;
+
+ ///
+ /// Retail's live skill-advancement-class table size. ACE terminates the
+ /// session on any other count — see the class doc comment.
+ ///
+ public const int SkillAdvancementClassCount = 55;
+
+ ///
+ /// The fourteen style/color strip fields plus the six f64 shade fields —
+ /// Appearance.Unpack's exact field set and order
+ /// (ACE.Entity/Appearance.cs).
+ ///
+ 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);
+
+ /// The six primary attributes, retail's fixed str/end/coord/quick/focus/self order.
+ public readonly record struct Attributes(
+ uint Strength,
+ uint Endurance,
+ uint Coordination,
+ uint Quickness,
+ uint Focus,
+ uint Self);
+
+ ///
+ /// Every field of an outbound CharacterCreate EXCEPT the account name
+ /// (a separate parameter, packed outside
+ /// CG_Pack — see the class doc comment) and the skill-advancement
+ /// array (a parameter so its length is
+ /// validated at the call site rather than smuggled through a record
+ /// field of unbounded size).
+ ///
+ 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);
+
+ ///
+ /// Build the body bytes for an outbound CharacterCreate request.
+ /// See the class doc comment for the exact byte layout.
+ ///
+ ///
+ /// .Length is not exactly
+ /// — ACE terminates the session
+ /// on any other count, so this builder refuses to construct the request
+ /// at all rather than send something retail-invalid.
+ ///
+ public static byte[] BuildRequestBody(
+ string accountName,
+ Request request,
+ ReadOnlySpan 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/CharacterRestore.cs b/src/AcDream.Core.Net/Messages/CharacterRestore.cs
index 794cc50d..8d3caf90 100644
--- a/src/AcDream.Core.Net/Messages/CharacterRestore.cs
+++ b/src/AcDream.Core.Net/Messages/CharacterRestore.cs
@@ -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 verificationFlag == 1. 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
-/// 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
+/// ( vs.
+/// )
+/// it is awaiting a reply to.
+///
+///
+///
+/// Campaign CC CC2 update: character creation now exists
+/// (), so the
+/// disambiguation this doc comment used to defer is real work now, done by
+/// WorldSession's awaiting-request latch (set by
+/// WorldSession.SendRestoreCharacter /
+/// WorldSession.SendCharacterCreation, cleared on the matching
+/// response), which routes each 0xF643 to
+/// WorldSession.CharacterRestoreReceived or
+/// WorldSession.CharacterCreateResponseReceived accordingly and drops
+/// (rather than misattributes) a 0xF643 with no outstanding request. The
+/// wire parse itself is now shared: delegates to
+/// , which both families
+/// consume. This type's own shape and
+/// signature are UNCHANGED by that refactor — every existing caller and test
+/// keeps working exactly as before.
///
///
public static class CharacterRestore
@@ -119,32 +135,14 @@ public static class CharacterRestore
///
/// Parse a CharacterRestore response body (opcode 0xF643).
- /// must start with the 4-byte opcode.
+ /// must start with the 4-byte opcode. Delegates
+ /// to the shared (Campaign
+ /// CC CC2); this type's shape and this method's
+ /// exception behavior are unchanged from before that refactor.
///
public static Parsed Parse(ReadOnlySpan 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 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);
}
}
diff --git a/src/AcDream.Core.Net/Packets/PacketWriter.cs b/src/AcDream.Core.Net/Packets/PacketWriter.cs
index f7edd92a..54e633a1 100644
--- a/src/AcDream.Core.Net/Packets/PacketWriter.cs
+++ b/src/AcDream.Core.Net/Packets/PacketWriter.cs
@@ -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;
+ }
+
/// Pad with zeros so the buffer length is a multiple of 4.
public void AlignTo4()
{
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 04d4397f..9d2397b9 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -598,6 +598,16 @@ public sealed class WorldSession : IDisposable
public event Action? CharacterListReceived;
public event Action? CharacterDeleteAcknowledged;
public event Action? CharacterRestoreReceived;
+ ///
+ /// Campaign CC CC2: fires when a 0xF643
+ /// () response arrives while
+ /// this session's awaiting-request latch says Create — i.e. the
+ /// reply to . See
+ /// 's doc comment for the
+ /// opcode collision with and how
+ /// the two are disambiguated.
+ ///
+ public event Action? CharacterCreateResponseReceived;
public event Action? CharacterErrorReceived;
///
/// Campaign LA gate round 2 finding 3: ACE sends this in the same batch
@@ -706,6 +716,41 @@ public sealed class WorldSession : IDisposable
public ServerName.Parsed? ServerInfo { get; private set; }
private CharacterError.Parsed? _lastCharacterSelectionError;
+ ///
+ /// Campaign CC CC2: which outbound character-generation request (if any)
+ /// this session is awaiting a 0xF643
+ /// () reply to. Restore and
+ /// create requests share that opcode on the wire (see
+ /// 's doc comment) with no
+ /// self-describing discriminant, so this latch is the only thing that
+ /// tells the dispatcher which event to fire. Set by
+ /// /
+ /// 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
+ /// (). Read/written only from the caller's frame
+ /// thread — the same single-threaded invariant every other per-session
+ /// field here (e.g. ) relies
+ /// on; is never invoked concurrently with
+ /// a send (see the class doc comment's thread-id probe note).
+ ///
+ private enum PendingCharGenVerificationRequest
+ {
+ None,
+ Restore,
+ Create,
+ }
+
+ private PendingCharGenVerificationRequest _pendingCharGenVerification =
+ PendingCharGenVerificationRequest.None;
+
+ ///
+ /// 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.
+ ///
+ private bool _loggedUnexpectedCharGenVerificationResponse;
+
private readonly IWorldSessionTransport _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
private long _lastPingRequestTicks;
@@ -1823,18 +1868,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 +2306,39 @@ public sealed class WorldSession : IDisposable
///
/// Send retail CharacterRestore through the control queue. This is
/// deliberately non-blocking because ACE silently drops unknown guids.
+ /// Arms the awaiting-request latch as Restore BEFORE the send so
+ /// a reply that arrives on a later Tick is never misattributed to a
+ /// different request (Campaign CC CC2).
///
- public void SendRestoreCharacter(uint characterId) =>
+ public void SendRestoreCharacter(uint characterId)
+ {
+ _pendingCharGenVerification = PendingCharGenVerificationRequest.Restore;
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
+ }
+
+ ///
+ /// Send retail CharacterCreate (opcode 0xF656) through the
+ /// login/logon queue — Proto_UI::SendCharGenResult routes via
+ /// SendToLogon, the same queue
+ /// uses (see
+ /// 's class doc comment). Deliberately
+ /// non-blocking, matching — ACE
+ /// silently drops a request whose packed account name doesn't match the
+ /// session's own account. Arms the awaiting-request latch as
+ /// Create BEFORE the send (Campaign CC CC2).
+ ///
+ public void SendCharacterCreation(
+ string accountName,
+ CharacterCreate.Request request,
+ ReadOnlySpan skillAdvancementClasses)
+ {
+ byte[] body = CharacterCreate.BuildRequestBody(
+ accountName,
+ request,
+ skillAdvancementClasses);
+ _pendingCharGenVerification = PendingCharGenVerificationRequest.Create;
+ SendGameMessage(body, GameMessageGroup.LoginQueue);
+ }
///
/// Phase I.3: test-only hook. When non-null,
@@ -3177,6 +3290,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,
diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
index 2efbf5a9..2274baa8 100644
--- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs
+++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
@@ -44,6 +44,36 @@ public sealed record EnteredWorldStatusEvent : StatusEvent
public required string CharacterName { get; init; }
}
+///
+/// Campaign CC CC2: the Ok reply to an outbound CharacterCreate (opcode
+/// 0xF656). / mirror the shared
+/// 0xF643 CharGenVerificationResponse Ok identity payload's own
+/// field names — deliberately distinct from 's
+/// characterId/characterName, since retail logs a freshly
+/// created character straight in without a fresh characterList, so
+/// this event can precede an for the
+/// same character rather than replace it.
+///
+public sealed record CharacterCreatedStatusEvent : StatusEvent
+{
+ public required uint Guid { get; init; }
+
+ public required string Name { get; init; }
+}
+
+///
+/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate.
+/// is the raw wire
+/// CharGenVerificationResponse.Code value; is that
+/// code's enum member name (e.g. "NameInUse").
+///
+public sealed record CreationFailedStatusEvent : StatusEvent
+{
+ public required uint Code { get; init; }
+
+ public required string Name { get; init; }
+}
+
public sealed record PluginLoadedStatusEvent : StatusEvent
{
public required string Plugin { get; init; }
diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
index 4f5000da..9967613d 100644
--- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
+++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
@@ -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,38 @@ 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"),
+ Name = RequireString(root, "name"),
+ };
+
private static StatusEvent ParsePluginLoaded(
JsonElement root,
int v,
diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs
index c5a01d95..e0ce20d0 100644
--- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs
+++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs
@@ -207,6 +207,47 @@ public sealed class SessionStatusWriter
characterName,
});
+ ///
+ /// Campaign CC CC2: the retail 0xF643 Ok response to an outbound
+ /// CharacterCreate — see
+ /// AcDream.Core.Net.Messages.CharGenVerificationResponse.
+ /// and come straight off that response's Ok
+ /// identity payload. This is a distinct event from :
+ /// 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
+ /// for the same character, not replace it.
+ ///
+ public void CharacterCreated(string sessionId, uint guid, string name) =>
+ Write(new
+ {
+ v = VocabularyVersion,
+ e = "characterCreated",
+ t = Now(),
+ sessionId,
+ guid,
+ name,
+ });
+
+ ///
+ /// Campaign CC CC2: a non-Ok 0xF643 response to an outbound
+ /// CharacterCreate. is the raw wire
+ /// CharGenVerificationResponse.Code value;
+ /// is that code's enum member name (e.g. "NameInUse") so a
+ /// launcher can render a readable reason without hard-coding the
+ /// server's numeric-to-dialog mapping itself.
+ ///
+ public void CreationFailed(string sessionId, uint code, string name) =>
+ Write(new
+ {
+ v = VocabularyVersion,
+ e = "creationFailed",
+ t = Now(),
+ sessionId,
+ code,
+ name,
+ });
+
public void PluginLoaded(string sessionId, string plugin) =>
Write(new
{
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs
new file mode 100644
index 00000000..5ce2a78b
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs
@@ -0,0 +1,110 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Campaign CC CC2: the shared 0xF643 parser both CharacterRestore and
+/// CharacterCreate responses consume. See
+/// for the pre-existing CharacterRestore-shaped coverage that must survive
+/// this type's promotion unchanged.
+///
+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(() => CharGenVerificationResponse.Parse(bytes));
+ }
+
+ [Fact]
+ public void Parse_TruncatedAfterCode_Throws()
+ {
+ var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode)
+ .Write((uint)CharGenVerificationResponse.Code.Ok);
+
+ Assert.Throws(() => CharGenVerificationResponse.Parse(w.ToArray()));
+ }
+
+ [Fact]
+ public void Parse_TruncatedBeforeCode_Throws()
+ {
+ var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode);
+
+ Assert.Throws(() => 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);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs
new file mode 100644
index 00000000..8080df08
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs
@@ -0,0 +1,308 @@
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// 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
+/// 's class doc comment for the decompiled
+/// source of truth).
+///
+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(() =>
+ CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills().AsSpan(0, 54)));
+ Assert.Throws(() =>
+ CharacterCreate.BuildRequestBody("testaccount", request, new uint[56]));
+ Assert.Throws(() =>
+ CharacterCreate.BuildRequestBody("testaccount", request, ReadOnlySpan.Empty));
+ }
+
+ [Fact]
+ public void BuildRequestBody_NullAccountName_Throws()
+ {
+ CharacterCreate.Request request = MakeRequest();
+ Assert.Throws(() =>
+ CharacterCreate.BuildRequestBody(null!, request, MakeSkills()));
+ }
+
+ [Fact]
+ public void BuildRequestBody_NullCharacterName_Throws()
+ {
+ CharacterCreate.Request request = MakeRequest() with { Name = null! };
+ Assert.Throws(() =>
+ 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);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs
new file mode 100644
index 00000000..0fb4f7ef
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs
@@ -0,0 +1,287 @@
+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;
+
+///
+/// Campaign CC CC2: the awaiting-request latch that disambiguates the two
+/// message families sharing opcode 0xF643 (see
+/// '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
+/// for the general
+/// character-management wire-order coverage this file complements.
+///
+public sealed class WorldSessionCharacterCreationTests
+{
+ private sealed class NullTransport : IWorldSessionTransport
+ {
+ public void Send(ReadOnlySpan datagram) { }
+ public void Send(IPEndPoint remote, ReadOnlySpan datagram) { }
+ public int Receive(
+ Span destination,
+ TimeSpan timeout,
+ out IPEndPoint? from)
+ {
+ from = null;
+ return -1;
+ }
+ public ValueTask ReceiveAsync(
+ Memory destination,
+ CancellationToken cancellationToken) =>
+ ValueTask.FromCanceled(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(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();
+ var restoreEvents = new List();
+ 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();
+ var createEvents = new List();
+ 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);
+ }
+
+ [Fact]
+ public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed()
+ {
+ using WorldSession session = CreateSession();
+
+ var restoreEvents = new List();
+ var createEvents = new List();
+ 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();
+ 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(() =>
+ session.SendCharacterCreation(
+ "testaccount",
+ MakeCreateRequest(),
+ new uint[10]));
+
+ Assert.Equal(PendingLatch.None, ReadPendingLatch(session));
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs
index 74cba578..3d3b6dc6 100644
--- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs
@@ -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();
session.CharacterListReceived += roster =>
events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}");
diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
index 2055ae92..10904453 100644
--- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
@@ -77,6 +77,35 @@ public sealed class StatusEventParserTests
Assert.Equal("boom", failed.Error);
}
+ [Fact]
+ public void ParsesCharacterCreatedAndCreationFailed()
+ {
+ var created = Assert.IsType(
+ 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(
+ StatusEventParser.Parse(
+ """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}"""));
+ Assert.Equal(3u, failed.Code);
+ Assert.Equal("NameInUse", 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\",\"name\":\"NameInUse\"}")]
+ [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3}")]
+ public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line)
+ {
+ var malformed = Assert.IsType(StatusEventParser.Parse(line));
+
+ Assert.Equal("s1", malformed.SessionId);
+ Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
+ }
+
[Fact]
public void ParsesLoginCommandFailed()
{
diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
index d2ad011c..bf30d037 100644
--- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
@@ -183,6 +183,33 @@ public sealed class StatusFileTailerTests : IDisposable
Assert.Empty(events);
}
+ ///
+ /// Campaign CC CC2: the two creation-flow events round-trip through the
+ /// actual file-tailing pipeline (not just
+ /// in isolation) — matching the exact camelCase shape
+ /// AcDream.Runtime.Session.SessionStatusWriter writes.
+ ///
+ [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,"name":"NameInUse"}"""
+ + "\n");
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Equal(2, events.Count);
+ var created = Assert.IsType(events[0]);
+ Assert.Equal(1342177296u, created.Guid);
+ Assert.Equal("NewChar", created.Name);
+ var failed = Assert.IsType(events[1]);
+ Assert.Equal(3u, failed.Code);
+ Assert.Equal("NameInUse", failed.Name);
+ }
+
[Fact]
public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced()
{
diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
index 95e37ff8..d9b0892a 100644
--- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
@@ -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");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@@ -116,6 +118,41 @@ public sealed class SessionStatusWriterTests
Assert.False(File.Exists(file.Path));
}
+ ///
+ /// Campaign CC CC2: pins the exact shape of the two new creation-flow
+ /// status events, added to the LA1 vocabulary alongside
+ /// CharacterCreate (opcode 0xF656) — see
+ /// docs/plans/2026-08-14-launcher-campaign.md §LA1's amended
+ /// status-vocabulary text.
+ ///
+ [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");
+
+ 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("name").GetString());
+ AssertExactProperties(lines[1], "v", "e", "t", "sessionId", "code", "name");
+ }
+
[Fact]
public void BlankPathIsTreatedAsAbsent()
{