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;
}
}