feat(net,runtime): Campaign CC CC2 — CharacterCreate wire, 0xF643 correlation, creation status events

Wire (Core.Net):
- CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of
  Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570
  -> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack),
  then the constant-1 u32, heritage/gender, 14 appearance strip/style/color
  u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail
  order), template, 6 attributes, slot, classId, numSkills + exactly 55
  u32 skill-advancement classes (ReadOnlySpan validated ==55, throws
  ArgumentException otherwise — ACE terminates the session on any other
  count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name
  String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose
  exact 19-term accumulation set (heritage+gender+3 strips+hairColor+
  eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+
  template+6 attributes) is read byte-for-byte off CG_Pack's decompiled
  accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/
  trousersColor/footwearColor/shades/slot/classId are deliberately absent
  from the sum despite sitting adjacent on the wire. Cross-checked against
  ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's
  CharacterCreateRequestData (types.rs:236-369), which agree on every
  field and order. Retail routes via SendToLogon — the same queue
  CharacterDelete already uses.
- CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse
  out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7,
  ACE's CharacterGenerationVerificationResponse) plus the conditional
  Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut).
  CharacterRestore.Parse now delegates to it; CharacterRestore's public
  Parsed shape, Parse signature, and every existing test expectation are
  UNCHANGED.
- PacketWriter.WriteDouble: f64 little-endian helper for the shade fields.

WorldSession dispatch (Core.Net):
- Added an awaiting-request latch (None/Restore/Create), armed by
  SendRestoreCharacter/the new SendCharacterCreation immediately before
  each send (SendCharacterCreation builds the body first so a skill-count
  throw never arms the latch for a request that was never sent), cleared
  the instant a matching 0xF643 is dispatched (success OR parse failure —
  a malformed reply must never wedge the latch open) and on Dispose.
  0xF643 now routes to CharacterRestoreReceived or the new
  CharacterCreateResponseReceived (Action<CharGenVerificationResponse.Parsed>)
  by that latch; an unexpected 0xF643 with nothing outstanding logs once
  and is dropped, never misattributed. Fixed
  WorldSessionCharacterSelectionTests' restore-dispatch test, which
  previously fed a bare CharacterRestore response with no preceding
  SendRestoreCharacter — that shape is now the "no outstanding request"
  drop path by design.

Status events (Runtime + Launcher.Core, contract first):
- Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status
  vocabulary to add characterCreated{guid,name} (Ok reply identity, named
  to mirror CharGenVerificationResponse's own fields and to read distinct
  from enteredWorld — retail logs a freshly created character straight in
  without a fresh characterList) and creationFailed{code,name} (raw Code
  value + its enum member name).
- SessionStatusWriter.CharacterCreated/CreationFailed implement that
  contract.
- Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent +
  StatusEventParser cases, in lockstep.

Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set,
55-slot fixture, wrong-count throws), CharGenVerificationResponseTests
(every Code value), WorldSessionCharacterCreationTests (create-then-
response routes correctly, restore unaffected, no-outstanding drop,
second-response-after-consumed drop, Dispose clears the latch, a builder
throw never arms it), SessionStatusWriterTests + Launcher.Core
StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer
round-trip) for the two new events.

Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution
test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667,
Launcher.Core.Tests 323/323, plus every other project in the solution).
WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 12:49:52 +02:00
parent ee80138fba
commit 5eaad2c88c
16 changed files with 1548 additions and 40 deletions

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,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

View file

@ -0,0 +1,145 @@
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).
/// </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,305 @@
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>
/// </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,41 @@ 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. 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"/>). 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 the class doc comment's thread-id probe note).
/// </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 +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
/// <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 so
/// a reply that arrives on a later Tick is never misattributed to a
/// different request (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 (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 +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,

View file

@ -44,6 +44,36 @@ 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="Name"/> is that
/// code's enum member name (e.g. <c>"NameInUse"</c>).
/// </summary>
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; }

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,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,

View file

@ -207,6 +207,47 @@ 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="name"/>
/// 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.
/// </summary>
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
{

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,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;
/// <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);
}
[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,35 @@ 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,"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<MalformedStatusEvent>(StatusEventParser.Parse(line));
Assert.Equal("s1", malformed.SessionId);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
[Fact]
public void ParsesLoginCommandFailed()
{

View file

@ -183,6 +183,33 @@ 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,"name":"NameInUse"}"""
+ "\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.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");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@ -116,6 +118,41 @@ 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");
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()
{