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.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++; public void EnterWorldByGuid( WorldSession session, uint characterGuid, string accountName) => EnterWorldByGuidCalls.Add((characterGuid, accountName)); 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; } = []; 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); public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => Failed.Add(rejection); } 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); } [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. } 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); } 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; } }