acdream/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs
Erik 2176ba768e fix(chargen): Campaign CC CC7 review fix round — F1-F9 — REVIEW-CLOSED
Both dual-lens reviewers of `9cf6c522`+`ddcbf1fb` returned PASS-with-items.
This round closes all nine findings:

F1 files AP-229 for the screen-layering divergence (retail destroys/
reconstructs the current UI framework via UIFlow::UseNewMode; acdream
keeps both CharacterManagementUiController and CharacterCreationUiController
mounted for the whole lifetime and reveals/occludes) plus its narrow
residual risk (the shared RetailDialogFactory can hand UiRoot.Modal to a
dialog opened by the still-ticking, occluded management screen on an
inbound CharacterError) and what already matches retail (selection/
world-name persistence, click-through isolation, one coherent Modal
stack).

F2 rewrites the connected-gate script's roster-full step with the exact
`@modifylong max_chars_per_account` recipe and the pending-delete-counts
note. F3 adds AP-221's console-diagnostic lines to the known-gaps
paragraph. F4 adds an empty-name/AP-227 step. F9 notes that a uniform
Random pick over 13 heritages can repeat.

F5 adds an App-layer source-text pin
(GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBinds
CharacterCreatedAndCreationFailedToTheStatusWriter) for the delegate
wiring the reviewer proved was deletable without breaking any test — no
practical seam exists to construct LiveSessionRuntimeFactory without a
GameWindow, so this follows the file's own established source-text-pin
pattern; the payload shape is already pinned separately at
SessionStatusWriterTests.

F6 corrects the CC7 ledger's checksum-assertion wording (it is a
round-trip purity check, not an independent golden — the golden is
CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet) and
cross-references it from the test's own doc comment.

F7 corrects the CC7 ledger's fixture-ordering claim (it had chargen
constructing first, backwards from RetailUiRuntime.Tick's real
management-then-chargen order) and reorders CharacterScreensFixedCanvas
ArbiterTests to match production, adding ClickThrough/ZOrder assertions
that pin the occlusion the reviewer previously verified only by hand.

F8 records a known flake (RuntimeCollisionReportingStateTests.
WarmedSteadyContactRefreshDoesNotAllocate, allocation-assertion load
sensitivity, pre-existing) seen under full-solution parallel load on
both reviewer runs.

Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the
campaign is CODE-COMPLETE pending the user's own connected gate.

Runtime 1735/0 (unchanged), App 5257/3 skips (+1: the new F5 pin).
Full Release build: 0 warnings, 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 03:13:02 +02:00

899 lines
43 KiB
C#

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;
/// <summary>
/// Campaign CC slice CC3: <see cref="LiveSessionController"/>'s
/// character-creation integration — the wire send (via a REAL
/// <see cref="WorldSession"/> + <c>GameMessageCapture</c>, the same seam
/// <c>WorldSessionCharacterCreationTests</c> uses in Core.Net) and the
/// Ok-response round trip (roster append reusing
/// <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>, the reused
/// <c>EnterSelectedCore</c> log-straight-in, and the new
/// <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/>/
/// <see cref="ILiveSessionLifecycleHost.ApplyCreationFailed"/> hooks) via a
/// real inbound <c>0xF643</c> packet through <c>WorldSession.ProcessDatagram</c>
/// (reflection — the same private test seam Core.Net's own creation tests
/// use).
/// </summary>
public sealed class LiveSessionControllerCharacterCreationTests
{
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
private sealed class TestOperations : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];
public int EnterWorldCount { get; private set; }
/// <summary>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.</summary>
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) { }
/// <summary>Two pre-existing characters whose WIRE order (array
/// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from
/// their ALPHABETICAL display order ("Amy" &lt; "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.</summary>
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++;
/// <summary>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.</summary>
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<LiveSessionRosterReport> Rosters { get; } = [];
public List<LiveSessionCharacterSelection> EnteredWorld { get; } = [];
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
public List<RuntimeCharacterCreationRejection> Failed { get; } = [];
/// <summary>
/// Campaign CC slice CC7 item 3: when set, forwards exactly the way
/// production hosts do (<c>LiveSessionRuntimeFactory.Create</c>'s
/// own <c>CharacterCreated</c>/<c>CreationFailed</c> delegates,
/// <c>HeadlessSessionHost</c>'s identical pair) — the SAME real
/// <see cref="SessionStatusWriter"/> a launcher-composed session
/// would use, not a re-implemented shape.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// F3 acceptance gate: retail does NOT force a full attribute spend —
/// <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c> only warns
/// (<c>arg2 != 0 &amp;&amp; remainingAtrbCredits &gt; 0</c>) and the
/// credit-warning dialog's own confirm handler re-invokes
/// <c>DoFinish(this, 0)</c> (@0x004E98BB), which skips the check
/// entirely and sends. <see cref="LiveSessionController.Finish"/>'s
/// <c>confirmUnspentCredits</c> parameter is that <c>arg2 == 0</c> case.
/// </summary>
[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.
}
/// <summary>
/// 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 <see cref="WorldSession"/>, then
/// decoded field-by-field — including the trailing checksum, which the
/// pre-existing <see cref="Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName"/>
/// test above never checked — against exactly the shape
/// <c>CharacterCreateInfo.Unpack</c>/<c>Appearance.Unpack</c> parse (see
/// <see cref="CharacterCreate"/>'s own doc comment for the ACE
/// cross-reference).
/// </summary>
/// <remarks>
/// Campaign CC CC7 review-fix round, F6 (2026-08-16): the checksum
/// assertion below (<c>Assert.Equal(CharacterCreate.ComputeChecksum(r),
/// decoded.Checksum)</c>) is a ROUND-TRIP/PURITY check, not an
/// independent golden — it recomputes the SAME production
/// <see cref="CharacterCreate.ComputeChecksum"/> 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 <c>205u</c>) is pinned separately
/// at <c>CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet</c>
/// (<c>tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs</c>).
/// </remarks>
[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);
}
/// <summary>
/// Campaign CC slice CC7 item 2: the remaining <c>0xF643</c> rejection
/// codes beyond NameInUse (already covered by
/// <see cref="Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection"/>
/// 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
/// <c>RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...</c>
/// theory.
/// </summary>
[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"));
}
/// <summary>
/// Campaign CC slice CC7 item 3: the launcher payload cycle end to end —
/// a REAL Runtime state transition (Finish -&gt; real
/// <see cref="WorldSession"/> -&gt; real inbound <c>0xF643</c> Ok reply)
/// through the REAL <see cref="SessionStatusWriter"/>, wired exactly the
/// way <c>LiveSessionRuntimeFactory.Create</c> (App host) and
/// <c>HeadlessSessionHost</c> wire it (see <see cref="TestHost"/>'s own
/// doc comment), to the REAL Launcher.Core
/// <see cref="StatusFileTailer"/>'s parsed event. This is the piece
/// CC2's own tests never reached: <c>SessionStatusWriterTests</c> calls
/// the writer directly and asserts raw JSON; <c>StatusEventParserTests</c>
/// 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.
/// </summary>
[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<StatusEvent> events = tailer.ReadNewEvents();
CharacterCreatedStatusEvent created =
Assert.Single(events.OfType<CharacterCreatedStatusEvent>());
Assert.Equal("cc7-session", created.SessionId);
Assert.Equal(0x50001234u, created.Guid);
Assert.Equal("NewChar", created.Name);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
/// <summary>Sibling of the Ok test above for the non-Ok half of the
/// contract (<c>creationFailed{code,reason,name}</c>).</summary>
[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<StatusEvent> events = tailer.ReadNewEvents();
CreationFailedStatusEvent failed =
Assert.Single(events.OfType<CreationFailedStatusEvent>());
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<byte>(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);
/// <summary>Manual mirror of <see cref="CharacterCreate.BuildRequestBody"/>'s
/// exact field order — see that class's doc comment for the full
/// layout.</summary>
private static CapturedCreateRequest DecodeCreateRequest(ReadOnlySpan<byte> 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);
}
/// <summary>Campaign CC slice CC7: the FULL field set — every byte of
/// <see cref="CharacterCreate.BuildRequestBody"/>'s layout, unlike
/// <see cref="CapturedCreateRequest"/>/<see cref="DecodeCreateRequest"/>
/// above which only samples a handful of fields.</summary>
private readonly record struct DecodedFullRequest(
string AccountName,
uint Constant,
CharacterCreate.Request Request,
uint[] SkillAdvancementClasses,
uint Checksum);
private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan<byte> 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<byte> body, ref int pos)
{
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
return value;
}
private static double ReadF64(ReadOnlySpan<byte> body, ref int pos)
{
double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
pos += 8;
return value;
}
private static string ReadString16L(ReadOnlySpan<byte> 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;
}
}