From 5eaad2c88c9eec8870f236c9e37030cf78157122 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:49:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(net,runtime):=20Campaign=20CC=20CC2=20?= =?UTF-8?q?=E2=80=94=20CharacterCreate=20wire,=200xF643=20correlation,=20c?= =?UTF-8?q?reation=20status=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 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 --- docs/plans/2026-08-14-launcher-campaign.md | 21 +- .../Messages/CharGenVerificationResponse.cs | 145 +++++++++ .../Messages/CharacterCreate.cs | 305 +++++++++++++++++ .../Messages/CharacterRestore.cs | 58 ++-- src/AcDream.Core.Net/Packets/PacketWriter.cs | 7 + src/AcDream.Core.Net/WorldSession.cs | 138 +++++++- .../Status/StatusEvent.cs | 30 ++ .../Status/StatusEventParser.cs | 38 +++ .../Session/SessionStatusWriter.cs | 41 +++ .../CharGenVerificationResponseTests.cs | 110 +++++++ .../Messages/CharacterCreateTests.cs | 308 ++++++++++++++++++ .../WorldSessionCharacterCreationTests.cs | 287 ++++++++++++++++ .../WorldSessionCharacterSelectionTests.cs | 7 + .../Status/StatusEventParserTests.cs | 29 ++ .../Status/StatusFileTailerTests.cs | 27 ++ .../Session/SessionStatusWriterTests.cs | 37 +++ 16 files changed, 1548 insertions(+), 40 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs create mode 100644 src/AcDream.Core.Net/Messages/CharacterCreate.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs 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() {