using System.Buffers.Binary; using System.Net; using System.Reflection; using System.Text; using AcDream.Core.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; using AcDream.Launcher.Core.Status; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.Tests.CharGen; namespace AcDream.Runtime.Tests.Session; /// /// Campaign CC slice CC3: 's /// character-creation integration — the wire send (via a REAL /// + GameMessageCapture, the same seam /// WorldSessionCharacterCreationTests uses in Core.Net) and the /// Ok-response round trip (roster append reusing /// , the reused /// EnterSelectedCore log-straight-in, and the new /// / /// hooks) via a /// real inbound 0xF643 packet through WorldSession.ProcessDatagram /// (reflection — the same private test seam Core.Net's own creation tests /// use). /// public sealed class LiveSessionControllerCharacterCreationTests { private sealed class TestTransport : 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) => throw new OperationCanceledException(cancellationToken); public void Dispose() { } } private sealed class TestOperations : ILiveSessionOperations { public List Sessions { get; } = []; public int EnterWorldCount { get; private set; } /// F1/F11: captures every guid-based enter call so the /// post-create log-straight-in can be asserted against the EXACT /// identity sent, instead of a bare counter that cannot tell an /// index-based call from a guid-based one, or a right character /// from a wrong one. public List<(uint Guid, string AccountName)> EnterWorldByGuidCalls { get; } = []; public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { var session = new WorldSession(endpoint, new TestTransport()); Sessions.Add(session); return session; } public void Connect(WorldSession session, string user, string password) { } public void StartCharacterSelectionReceive(WorldSession session) { } /// Two pre-existing characters whose WIRE order (array /// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from /// their ALPHABETICAL display order ("Amy" < "Zed") — F2's /// regression gate. A single-character roster cannot distinguish a /// correct wire-index-preserving append from a buggy /// re-sort-and-renumber, because with only one entry the two orders /// coincide. public CharacterList.Parsed? GetCharacters(WorldSession session) => new( 0u, [ new CharacterList.Character(0x50000002u, "Zed", 0u), new CharacterList.Character(0x50000003u, "Amy", 0u), ], [], SlotCount: 11, AccountName: "testaccount", true, true); public void EnterWorld(WorldSession session, int activeCharacterIndex) => EnterWorldCount++; /// R1: when positive, the next guid-enter throws retail's /// server-rejection shape (the transport-valid path that returns /// the controller to selection), decrementing per call — lets a /// test reach the create-again-after-rejected-enter flow. public int EnterWorldByGuidRejectionsRemaining { get; set; } public void EnterWorldByGuid( WorldSession session, uint characterGuid, string accountName) { EnterWorldByGuidCalls.Add((characterGuid, accountName)); if (EnterWorldByGuidRejectionsRemaining > 0) { EnterWorldByGuidRejectionsRemaining--; throw new CharacterSelectionRejectedException( new CharacterError.Parsed(0x0000000Bu)); } } public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) { } } private sealed class TestHost : ILiveSessionLifecycleHost { public List Rosters { get; } = []; public List EnteredWorld { get; } = []; public List Created { get; } = []; public List Failed { get; } = []; /// /// Campaign CC slice CC7 item 3: when set, forwards exactly the way /// production hosts do (LiveSessionRuntimeFactory.Create's /// own CharacterCreated/CreationFailed delegates, /// HeadlessSessionHost's identical pair) — the SAME real /// a launcher-composed session /// would use, not a re-implemented shape. /// public SessionStatusWriter? Writer { get; set; } public string SessionId { get; set; } = "s1"; public LiveSessionBinding BindSession(WorldSession session) => new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } public void ReportConnecting(string host, int port, string user) { } public void ReportConnected() { } public void ReportRoster(LiveSessionRosterReport roster) => Rosters.Add(roster); public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => EnteredWorld.Add(selection); public void DetachSession(WorldSession session) { } public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) { Created.Add(identity); Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name); } public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) { Failed.Add(rejection); Writer?.CreationFailed( SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName); } } private static LiveSessionConnectOptions LiveOptions() => new( Enabled: true, "127.0.0.1", 9000, "testaccount", "password", Character: null, Probe: false, AwaitCharacterSelection: true); private static (LiveSessionController Controller, TestOperations Operations, TestHost Host, RuntimeGenerationToken Generation) StartAwaitingSelection() { var operations = new TestOperations(); var host = new TestHost(); var controller = new LiveSessionController( operations, timeProvider: null, RuntimeCharacterCreationStateFixture.Build()); LiveSessionStartResult result = controller.Start(LiveOptions(), host); Assert.Equal(LiveSessionStartStatus.AwaitingCharacterSelection, result.Status); return (controller, operations, host, controller.Generation); } private static void BuildReadyCharacter(LiveSessionController controller, RuntimeGenerationToken generation) { Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); Assert.True(controller.SetName(generation, "NewChar").Accepted); } [Fact] public void Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName() { (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; byte[]? captured = null; GameMessageGroup? capturedGroup = null; session.GameMessageCapture = (body, group) => { captured = body; capturedGroup = group; }; RuntimeCommandResult result = controller.Finish(generation); Assert.True(result.Accepted); Assert.NotNull(captured); Assert.Equal(GameMessageGroup.LoginQueue, capturedGroup); CapturedCreateRequest decoded = DecodeCreateRequest(captured!); Assert.Equal("testaccount", decoded.AccountName); Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, decoded.Heritage); Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, decoded.Gender); Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, decoded.Template); Assert.Equal(16u, decoded.Strength); Assert.Equal("NewChar", decoded.Name); Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, decoded.NumSkills); Assert.Equal(CharacterCreate.SkillAdvancementClassCount, decoded.SkillAdvancementClasses.Length); Assert.Equal( (uint)ChargenSkillAdvancementClass.Trained, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); Assert.Equal( (uint)ChargenSkillAdvancementClass.Specialized, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); } [Fact] public void Finish_ThenOkResponse_AppendsToRosterAndLogsStraightIn() { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); Assert.Single(host.Created); Assert.Equal(0x50001234u, host.Created[0].Guid); Assert.Equal("NewChar", host.Created[0].Name); Assert.Empty(host.Failed); // The roster report following the Ok reply has EVERY pre-existing // character and the newly created one. LiveSessionRosterReport lastReport = host.Rosters[^1]; Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar"); Assert.Contains(lastReport.Entries, e => e.Id == 0x50000002u && e.Name == "Zed"); Assert.Contains(lastReport.Entries, e => e.Id == 0x50000003u && e.Name == "Amy"); // F2 acceptance gate: the append preserves every PRE-EXISTING // character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot // 1 — deliberately NOT alphabetical order) and assigns the NEW // character the true wire count (2, 0-based, after Zed and Amy). A // round-trip through ApplyRoster's name-sort-and-renumber would // have swapped Zed/Amy to 1/0 instead. Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed)); Assert.Equal(0, zed.ActiveIndex); Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy)); Assert.Equal(1, amy.ActiveIndex); Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry newChar)); Assert.Equal(2, newChar.ActiveIndex); // F1 acceptance gate: gmCharGenMainUI::Update @ 0x004E8460's // log-straight-in, reused via EnterCreatedCharacterCore — the // controller is now in-world as the new character, entered by the // EXACT guid the Ok reply carried (NOT the roster-index path, which // the cached wire roster is by-design stale for post-create). Assert.True(controller.IsInWorld); Assert.Equal(0, operations.EnterWorldCount); Assert.Single(operations.EnterWorldByGuidCalls); Assert.Equal(0x50001234u, operations.EnterWorldByGuidCalls[0].Guid); Assert.Equal("testaccount", operations.EnterWorldByGuidCalls[0].AccountName); Assert.Single(host.EnteredWorld); Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); } /// /// CC3 re-review R1: a SECOND create in the same session must get wire /// slot N+1, not N. ACE never resends CharacterList post-create, so the /// cached wire count alone under-counts by the creates it hasn't seen; /// the controller's creates-since-list counter (reset on every fresh /// wire CharacterList) supplies the difference — the equivalent of /// retail's own CharacterSet growing via AddIdentity per create. The /// create-again path is reached exactly as the re-review described: /// first create Ok, guid-enter rejected by the server /// (CharacterSelectionRejectedException → ReturnToSelection), then a /// second create. /// [Fact] public void SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot() { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; operations.EnterWorldByGuidRejectionsRemaining = 1; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); // The rejected enter left us back at selection with the first // created character appended at the true wire slot 2. Assert.False(controller.IsInWorld); Assert.Single(operations.EnterWorldByGuidCalls); Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry firstCreated)); Assert.Equal(2, firstCreated.ActiveIndex); // Second create in the same session: ACE's own list now holds // Zed(0), Amy(1), NewChar(2) — the cached wire list still only // holds Zed and Amy. The second character's slot must be 3. Assert.True(controller.SetName(generation, "SecondChar").Accepted); Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.Ok, 0x50005678u, "SecondChar")); Assert.True(controller.CharacterSelectionState.View.TryGet(0x50005678u, out RuntimeCharacterSelectionEntry secondCreated)); Assert.Equal(3, secondCreated.ActiveIndex); // Pre-existing wire indices still intact after both appends. Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed)); Assert.Equal(0, zed.ActiveIndex); Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy)); Assert.Equal(1, amy.ActiveIndex); Assert.Equal(2, host.Created.Count); } [Fact] public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection() { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); Assert.Single(host.Failed); Assert.Equal(CharGenVerificationResponse.Code.NameInUse, host.Failed[0].Code); Assert.Equal("NewChar", host.Failed[0].AttemptedName); Assert.Empty(host.Created); Assert.False(controller.IsInWorld); Assert.Equal(0, operations.EnterWorldCount); Assert.Empty(operations.EnterWorldByGuidCalls); Assert.Empty(host.EnteredWorld); // No roster append on a rejection. Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); } [Fact] public void Finish_RefusedLocallyWithUnspentAttributeCredits_NeverTouchesTheWire() { (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = StartAwaitingSelection(); Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted); Assert.True(controller.SetName(generation, "NewChar").Accepted); WorldSession session = operations.Sessions[0]; bool sent = false; session.GameMessageCapture = (_, _) => sent = true; RuntimeCommandResult result = controller.Finish(generation); Assert.False(result.Accepted); Assert.False(sent); } /// /// F3 acceptance gate: retail does NOT force a full attribute spend — /// gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170 only warns /// (arg2 != 0 && remainingAtrbCredits > 0) and the /// credit-warning dialog's own confirm handler re-invokes /// DoFinish(this, 0) (@0x004E98BB), which skips the check /// entirely and sends. 's /// confirmUnspentCredits parameter is that arg2 == 0 case. /// [Fact] public void Finish_WithUnspentCreditsAndConfirmed_SendsAnyway() { (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = StartAwaitingSelection(); Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted); Assert.True(controller.SetName(generation, "NewChar").Accepted); WorldSession session = operations.Sessions[0]; byte[]? captured = null; session.GameMessageCapture = (body, _) => captured = body; // Unconfirmed: refused, matching the sibling test above — the // warning-dialog gate. Assert.False(controller.Finish(generation).Accepted); Assert.Null(captured); // Confirmed: sends anyway with the credits still unspent. RuntimeCommandResult confirmed = controller.Finish(generation, confirmUnspentCredits: true); Assert.True(confirmed.Accepted); Assert.NotNull(captured); CapturedCreateRequest decoded = DecodeCreateRequest(captured!); Assert.Equal("NewChar", decoded.Name); Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged. } /// /// Campaign CC slice CC7 item 2: the SEAMLESS end-to-end walk the /// campaign closeout asks for — a fully populated creation (heritage, /// gender, appearance across every one of the fourteen style/color /// slots and all six shades, template, an EXPLICIT skill command beyond /// what the template alone applies, an explicit town/start-area /// selection, name) sent through a REAL , then /// decoded field-by-field — including the trailing checksum, which the /// pre-existing /// test above never checked — against exactly the shape /// CharacterCreateInfo.Unpack/Appearance.Unpack parse (see /// 's own doc comment for the ACE /// cross-reference). /// /// /// Campaign CC CC7 review-fix round, F6 (2026-08-16): the checksum /// assertion below (Assert.Equal(CharacterCreate.ComputeChecksum(r), /// decoded.Checksum)) is a ROUND-TRIP/PURITY check, not an /// independent golden — it recomputes the SAME production /// formula the encode side /// already used, rather than re-deriving the sum a second time by hand, /// so it proves the wire-encode/decode round trip is lossless but /// cannot by itself catch a bug shared by both the encoder and this /// formula. The checksum's actual golden value (the 19-term retail /// accumulation set, hand-summed to 205u) is pinned separately /// at CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet /// (tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs). /// [Fact] public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape() { (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = StartAwaitingSelection(); Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyesStrip, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.NoseStrip, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.MouthStrip, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairStyle, 1u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairColor, 1u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyeColor, 1u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearStyle, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearColor, 2u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtStyle, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtColor, 1u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersStyle, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersColor, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearStyle, 0u).Accepted); Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearColor, 2u).Accepted); Assert.True(controller.SetShade(generation, ChargenShadeSlot.Skin, 0.25).Accepted); Assert.True(controller.SetShade(generation, ChargenShadeSlot.Hair, 0.5).Accepted); Assert.True(controller.SetShade(generation, ChargenShadeSlot.Headgear, 0.75).Accepted); Assert.True(controller.SetShade(generation, ChargenShadeSlot.Shirt, 0.1).Accepted); Assert.True(controller.SetShade(generation, ChargenShadeSlot.Trousers, 0.9).Accepted); Assert.True(controller.SetShade(generation, ChargenShadeSlot.Footwear, 0.6).Accepted); Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); // Explicit skill command beyond the template's own Normal/Primary // lists (SkillFreeTrained costs 0 to train — no credit-budget risk). Assert.True(controller.TrainSkill(generation, RuntimeCharacterCreationStateFixture.SkillFreeTrained).Accepted); // Town: the fixture's global starter-area list is [Holtburg(0), Yaraq(1)]. Assert.True(controller.SelectStartArea(generation, 1).Accepted); Assert.True(controller.SetName(generation, "FullChar").Accepted); WorldSession session = operations.Sessions[0]; byte[]? captured = null; session.GameMessageCapture = (body, _) => captured = body; Assert.True(controller.Finish(generation).Accepted); Assert.NotNull(captured); DecodedFullRequest decoded = DecodeCreateRequestFull(captured!); Assert.Equal("testaccount", decoded.AccountName); Assert.Equal(1u, decoded.Constant); CharacterCreate.Request r = decoded.Request; Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, r.Heritage); Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, r.Gender); Assert.Equal(0u, r.Appearance.EyesStrip); Assert.Equal(0u, r.Appearance.NoseStrip); Assert.Equal(0u, r.Appearance.MouthStrip); Assert.Equal(1u, r.Appearance.HairColor); Assert.Equal(1u, r.Appearance.EyeColor); Assert.Equal(1u, r.Appearance.HairStyle); Assert.Equal(0u, r.Appearance.HeadgearStyle); Assert.Equal(2u, r.Appearance.HeadgearColor); Assert.Equal(0u, r.Appearance.ShirtStyle); Assert.Equal(1u, r.Appearance.ShirtColor); Assert.Equal(0u, r.Appearance.TrousersStyle); Assert.Equal(0u, r.Appearance.TrousersColor); Assert.Equal(0u, r.Appearance.FootwearStyle); Assert.Equal(2u, r.Appearance.FootwearColor); Assert.Equal(0.25, r.Appearance.SkinShade); Assert.Equal(0.5, r.Appearance.HairShade); Assert.Equal(0.75, r.Appearance.HeadgearShade); Assert.Equal(0.1, r.Appearance.ShirtShade); Assert.Equal(0.9, r.Appearance.TrousersShade); Assert.Equal(0.6, r.Appearance.FootwearShade); Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, r.Template); Assert.Equal(16u, r.Attributes.Strength); Assert.Equal(10u, r.Attributes.Endurance); Assert.Equal(10u, r.Attributes.Coordination); Assert.Equal(10u, r.Attributes.Quickness); Assert.Equal(10u, r.Attributes.Focus); Assert.Equal(10u, r.Attributes.Self); Assert.Equal(0u, r.Slot); // classId: register AP-209's documented placeholder — ACE ignores // this field (retail's DAT DID lookup has no Core equivalent). Assert.Equal(0u, r.ClassId); Assert.Equal( (uint)CharacterCreate.SkillAdvancementClassCount, (uint)decoded.SkillAdvancementClasses.Length); Assert.Equal( (uint)ChargenSkillAdvancementClass.Trained, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); Assert.Equal( (uint)ChargenSkillAdvancementClass.Specialized, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); Assert.Equal( (uint)ChargenSkillAdvancementClass.Trained, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillFreeTrained]); Assert.Equal("FullChar", r.Name); Assert.Equal(1u, r.StartArea); Assert.False(r.IsAdmin); Assert.False(r.IsEnvoy); // The trailing checksum (CG_Pack@0x005c74c3's final store) — never // read by ACE, sent for byte fidelity with a genuine retail client. Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum); } /// /// Campaign CC slice CC7 item 2: the remaining 0xF643 rejection /// codes beyond NameInUse (already covered by /// /// above) — proves CC5's F2 fix (Pending/Undef produce a real rejection /// instead of a silent reset) holds over the REAL wire decode path, not /// just the isolated state-machine /// RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_... /// theory. /// [Theory] [InlineData(CharGenVerificationResponse.Code.Pending)] [InlineData(CharGenVerificationResponse.Code.NameBanned)] [InlineData(CharGenVerificationResponse.Code.Corrupt)] [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] [InlineData(CharGenVerificationResponse.Code.Undef)] public void Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect( CharGenVerificationResponse.Code code) { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket((uint)code, 0u, string.Empty)); Assert.Single(host.Failed); Assert.Equal((uint)code, host.Failed[0].RawCode); Assert.Equal(code, host.Failed[0].Code); Assert.Equal(code.ToString(), host.Failed[0].Reason); Assert.Equal("NewChar", host.Failed[0].AttemptedName); Assert.Empty(host.Created); Assert.False(controller.IsInWorld); Assert.Empty(operations.EnterWorldByGuidCalls); Assert.Empty(host.EnteredWorld); Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); } /// /// Campaign CC slice CC7 item 3: the launcher payload cycle end to end — /// a REAL Runtime state transition (Finish -> real /// -> real inbound 0xF643 Ok reply) /// through the REAL , wired exactly the /// way LiveSessionRuntimeFactory.Create (App host) and /// HeadlessSessionHost wire it (see 's own /// doc comment), to the REAL Launcher.Core /// 's parsed event. This is the piece /// CC2's own tests never reached: SessionStatusWriterTests calls /// the writer directly and asserts raw JSON; StatusEventParserTests /// parses a hand-written JSON literal; neither drives a create through /// Runtime first, so a wiring gap between Runtime's own state machine /// and the writer (or between the writer's bytes and the tailer's /// parser) would not have been caught by either. /// [Fact] public void Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer() { string path = Path.Combine( Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl"); try { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); host.Writer = new SessionStatusWriter(path); host.SessionId = "cc7-session"; BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); // Runtime's own side of the contract already fired. Assert.Single(host.Created); var tailer = new StatusFileTailer(path); IReadOnlyList events = tailer.ReadNewEvents(); CharacterCreatedStatusEvent created = Assert.Single(events.OfType()); Assert.Equal("cc7-session", created.SessionId); Assert.Equal(0x50001234u, created.Guid); Assert.Equal("NewChar", created.Name); } finally { if (File.Exists(path)) File.Delete(path); } } /// Sibling of the Ok test above for the non-Ok half of the /// contract (creationFailed{code,reason,name}). [Fact] public void Finish_ThenNameInUseResponse_WritesCreationFailedEvent_ParsedByTheRealLauncherTailer() { string path = Path.Combine( Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl"); try { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); host.Writer = new SessionStatusWriter(path); host.SessionId = "cc7-session"; BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); Assert.Single(host.Failed); var tailer = new StatusFileTailer(path); IReadOnlyList events = tailer.ReadNewEvents(); CreationFailedStatusEvent failed = Assert.Single(events.OfType()); Assert.Equal("cc7-session", failed.SessionId); Assert.Equal((uint)CharGenVerificationResponse.Code.NameInUse, failed.Code); Assert.Equal("NameInUse", failed.Reason); Assert.Equal("NewChar", failed.Name); } finally { if (File.Exists(path)) File.Delete(path); } } 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 byte[] BuildResponseBody(uint code, uint guid, string name) { var w = new PacketWriter(); w.WriteUInt32(CharGenVerificationResponse.ResponseOpcode); w.WriteUInt32(code); if (code == (uint)CharGenVerificationResponse.Code.Ok) { w.WriteUInt32(guid); w.WriteString16L(name); w.WriteUInt32(0u); } return w.ToArray(); } private static byte[] BuildResponsePacket(uint code, uint guid, string name) { byte[] message = BuildResponseBody(code, guid, name); var fragments = new byte[MessageFragmentHeader.Size + message.Length]; GameMessageFragment.WriteSingleFragment(fragments.AsSpan(), fragmentSequence: 1u, GameMessageGroup.UIQueue, message); return PacketCodec.Encode( new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments }, fragments, outboundIsaac: null); } private readonly record struct CapturedCreateRequest( string AccountName, uint Heritage, uint Gender, uint Template, uint Strength, string Name, uint NumSkills, uint[] SkillAdvancementClasses); /// Manual mirror of 's /// exact field order — see that class's doc comment for the full /// layout. private static CapturedCreateRequest DecodeCreateRequest(ReadOnlySpan body) { int pos = 0; uint opcode = ReadU32(body, ref pos); Assert.Equal(CharacterCreate.Opcode, opcode); string accountName = ReadString16L(body, ref pos); uint constant = ReadU32(body, ref pos); Assert.Equal(1u, constant); uint heritage = ReadU32(body, ref pos); uint gender = ReadU32(body, ref pos); _ = ReadU32(body, ref pos); // eyesStrip _ = ReadU32(body, ref pos); // noseStrip _ = ReadU32(body, ref pos); // mouthStrip _ = ReadU32(body, ref pos); // hairColor _ = ReadU32(body, ref pos); // eyeColor _ = ReadU32(body, ref pos); // hairStyle _ = ReadU32(body, ref pos); // headgearStyle _ = ReadU32(body, ref pos); // headgearColor _ = ReadU32(body, ref pos); // shirtStyle _ = ReadU32(body, ref pos); // shirtColor _ = ReadU32(body, ref pos); // trousersStyle _ = ReadU32(body, ref pos); // trousersColor _ = ReadU32(body, ref pos); // footwearStyle _ = ReadU32(body, ref pos); // footwearColor for (int i = 0; i < 6; i++) _ = ReadF64(body, ref pos); // six shades uint template = ReadU32(body, ref pos); uint strength = ReadU32(body, ref pos); _ = ReadU32(body, ref pos); // endurance _ = ReadU32(body, ref pos); // coordination _ = ReadU32(body, ref pos); // quickness _ = ReadU32(body, ref pos); // focus _ = ReadU32(body, ref pos); // self _ = ReadU32(body, ref pos); // slot _ = ReadU32(body, ref pos); // classId uint numSkills = ReadU32(body, ref pos); var skills = new uint[numSkills]; for (int i = 0; i < numSkills; i++) skills[i] = ReadU32(body, ref pos); string name = ReadString16L(body, ref pos); // startArea, isAdmin, isEnvoy, checksum follow — not needed here. return new CapturedCreateRequest( accountName, heritage, gender, template, strength, name, numSkills, skills); } /// Campaign CC slice CC7: the FULL field set — every byte of /// 's layout, unlike /// / /// above which only samples a handful of fields. private readonly record struct DecodedFullRequest( string AccountName, uint Constant, CharacterCreate.Request Request, uint[] SkillAdvancementClasses, uint Checksum); private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan body) { int pos = 0; uint opcode = ReadU32(body, ref pos); Assert.Equal(CharacterCreate.Opcode, opcode); string accountName = ReadString16L(body, ref pos); uint constant = ReadU32(body, ref pos); uint heritage = ReadU32(body, ref pos); uint gender = ReadU32(body, ref pos); uint eyesStrip = ReadU32(body, ref pos); uint noseStrip = ReadU32(body, ref pos); uint mouthStrip = ReadU32(body, ref pos); uint hairColor = ReadU32(body, ref pos); uint eyeColor = ReadU32(body, ref pos); uint hairStyle = ReadU32(body, ref pos); uint headgearStyle = ReadU32(body, ref pos); uint headgearColor = ReadU32(body, ref pos); uint shirtStyle = ReadU32(body, ref pos); uint shirtColor = ReadU32(body, ref pos); uint trousersStyle = ReadU32(body, ref pos); uint trousersColor = ReadU32(body, ref pos); uint footwearStyle = ReadU32(body, ref pos); uint footwearColor = ReadU32(body, ref pos); double skinShade = ReadF64(body, ref pos); double hairShade = ReadF64(body, ref pos); double headgearShade = ReadF64(body, ref pos); double shirtShade = ReadF64(body, ref pos); double trousersShade = ReadF64(body, ref pos); double footwearShade = ReadF64(body, ref pos); uint template = ReadU32(body, ref pos); uint strength = ReadU32(body, ref pos); uint endurance = ReadU32(body, ref pos); uint coordination = ReadU32(body, ref pos); uint quickness = ReadU32(body, ref pos); uint focus = ReadU32(body, ref pos); uint self = ReadU32(body, ref pos); uint slot = ReadU32(body, ref pos); uint classId = ReadU32(body, ref pos); uint numSkills = ReadU32(body, ref pos); var skills = new uint[numSkills]; for (int i = 0; i < numSkills; i++) skills[i] = ReadU32(body, ref pos); string name = ReadString16L(body, ref pos); uint startArea = ReadU32(body, ref pos); uint isAdmin = ReadU32(body, ref pos); uint isEnvoy = ReadU32(body, ref pos); uint checksum = ReadU32(body, ref pos); // Nothing left over, nothing missing — the layout is exhaustive. Assert.Equal(body.Length, pos); var request = new CharacterCreate.Request( heritage, gender, new CharacterCreate.Appearance( eyesStrip, noseStrip, mouthStrip, hairColor, eyeColor, hairStyle, headgearStyle, headgearColor, shirtStyle, shirtColor, trousersStyle, trousersColor, footwearStyle, footwearColor, skinShade, hairShade, headgearShade, shirtShade, trousersShade, footwearShade), template, new CharacterCreate.Attributes( strength, endurance, coordination, quickness, focus, self), slot, classId, name, startArea, isAdmin != 0u, isEnvoy != 0u); return new DecodedFullRequest(accountName, constant, request, skills, checksum); } private static uint ReadU32(ReadOnlySpan body, ref int pos) { uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; return value; } private static double ReadF64(ReadOnlySpan body, ref int pos) { double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos)); pos += 8; return value; } private static string ReadString16L(ReadOnlySpan body, ref int pos) { ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); string value = Encoding.ASCII.GetString(body.Slice(pos + 2, len)); int recordSize = 2 + len; int padding = (4 - (recordSize & 3)) & 3; pos += recordSize + padding; return value; } }